diff --git a/.gitignore b/.gitignore index 27a2fb182..83c1397e6 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ d1gam3xoknrgr2.cloudfront.net/ merger-todos.md *.html +# private dev folder +dev/* diff --git a/config/config.default.yaml b/config/config.default.yaml index c4335ec5e..15af59344 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -480,6 +480,18 @@ sector: heat_exchanger_pinch_point_temperature_difference: 5 #K isentropic_compressor_efficiency: 0.8 heat_loss: 0.0 + heat_utilisation_potentials: + geothermal: + # activate for 85C hydrothermal + # key: hydrothermal_85 + # constant_temperature_celsius: 85 + key: hydrothermal_65 + constant_temperature_celsius: 65 + column_name: Energy_TWh + unit: TWh + full_load_hours: 4000 + direct_utilisation_heat_sources: + - geothermal heat_pump_sources: urban central: - air @@ -827,7 +839,7 @@ industry: # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#costs costs: year: 2030 - version: v0.9.2 + version: v0.10.0 social_discountrate: 0.02 fill_values: FOM: 0 @@ -1228,8 +1240,11 @@ plotting: services urban decentral air heat pump: '#5af95d' services rural air heat pump: '#5af95d' urban central air heat pump: '#6cfb6b' + urban central geothermal heat pump: '#4f2144' + geothermal heat pump: '#4f2144' + geothermal heat direct utilisation: '#ba91b1' ground heat pump: '#2fb537' - residential rural ground heat pump: '#48f74f' + residential rural ground heat pump: '#4f2144' residential rural air heat pump: '#48f74f' services rural ground heat pump: '#5af95d' Ambient: '#98eb9d' diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index 13246b27f..ba98adee0 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -23,6 +23,15 @@ district_heating,--,,`prepare_sector_network.py ) should be used, +-- -- geothermal,-,Name of the heat source. Must be the same as in ``heat_pump_sources``, +-- -- -- key,-,string used to complete URL for data download - e.g. `geothermal_65` or `geothermal_85`","i.e file names in `Fordatis `, +-- -- -- constant_temperature_celsius,°C,heat source temperature, +-- -- -- column_name,-,name of the data column in retrieved GeoDataFrame, + +-- -- -- unit,-,unit of heat source potential must be in (K/M/G/T)Wh, +-- -- -- full_load_hours,h,assumed full-load hours in Manz et al. (used to scale from utilisation to technical potential), +-- direct_utilisation_heat_sources,--,List of heat sources for direct heat utilisation in district heating. Must be in the keys of `heat_utilisation_potentials` (e.g. ``geothermal``), -- heat_pump_sources,--,, -- -- urban central,--,List of heat sources for heat pumps in urban central heating, -- -- urban decentral,--,List of heat sources for heat pumps in urban decentral heating, diff --git a/doc/data-retrieval.rst b/doc/data-retrieval.rst index 7509c8b57..3186157e7 100644 --- a/doc/data-retrieval.rst +++ b/doc/data-retrieval.rst @@ -9,6 +9,15 @@ Specific retrieval rules Data in this section is retrieved and extracted in rules specified in ``rules/retrieve.smk``. + +``data/fraunhofer_heat_source_utilisation_potentials`` + +- **Source:** Fraunhofer Fordatis +- **Link:** https://fordatis.fraunhofer.de/handle/fordatis/341.3?mode=simple +- **License:** `CC BY 4.0 `__ +- **Description:** Utilisation potentials for different heat sources across Europe, based on Manz et al. 2024. + + ``data/nuts`` - **Source:** GISCO diff --git a/doc/release_notes.rst b/doc/release_notes.rst index daefe6854..604537fdd 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,6 +11,8 @@ Release Notes Upcoming Release ================ +* Feature: Introduce geothermal district heating (direct utilisation and heat pumps). Potentials are based on `Manz et al. 2024: Spatial analysis of renewable and excess heat potentials for climate-neutral district heating in Europe `. + * Feature: Allow CHPs to use different fuel sources such as gas, oil, coal, and methanol. Note that the cost assumptions are based on a gas CHP (except for solid biomass-fired CHP). * Improve `sanitize_carrier`` function by filling in colors of missing carriers with colors mapped after using the function `rename_techs`. diff --git a/rules/build_sector.smk b/rules/build_sector.smk index c56675c17..04f5c3e8e 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -284,6 +284,28 @@ rule build_central_heating_temperature_profiles: "../scripts/build_central_heating_temperature_profiles/run.py" +rule build_heat_source_potentials: + params: + heat_utilisation_potentials=config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + ), + input: + utilisation_potential="data/heat_source_utilisation_potentials/{heat_source}.gpkg", + regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"), + output: + resources("heat_source_potential_{heat_source}_base_s_{clusters}.csv"), + resources: + mem_mb=2000, + log: + logs("build_heat_source_potentials_{heat_source}_s_{clusters}.log"), + benchmark: + benchmarks("build_heat_source_potentials/{heat_source}_s_{clusters}") + conda: + "../envs/environment.yaml" + script: + "../scripts/build_heat_source_potentials/run.py" + + rule build_cop_profiles: params: heat_pump_sink_T_decentral_heating=config_provider( @@ -296,6 +318,9 @@ rule build_cop_profiles: "sector", "district_heating", "heat_pump_cop_approximation" ), heat_pump_sources=config_provider("sector", "heat_pump_sources"), + heat_utilisation_potentials=config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + ), snapshots=config_provider("snapshots"), input: central_heating_forward_temperature_profiles=resources( @@ -321,6 +346,39 @@ rule build_cop_profiles: "../scripts/build_cop_profiles/run.py" +rule build_direct_heat_source_utilisation_profiles: + params: + direct_utilisation_heat_sources=config_provider( + "sector", "district_heating", "direct_utilisation_heat_sources" + ), + heat_utilisation_potentials=config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + ), + snapshots=config_provider("snapshots"), + input: + central_heating_forward_temperature_profiles=resources( + "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), + output: + direct_heat_source_utilisation_profiles=resources( + "direct_heat_source_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), + resources: + mem_mb=20000, + log: + logs( + "build_direct_heat_source_utilisation_profiles_s_{clusters}_{planning_horizons}.log" + ), + benchmark: + benchmarks( + "build_direct_heat_source_utilisation_profiles/s_{clusters}_{planning_horizons}" + ) + conda: + "../envs/environment.yaml" + script: + "../scripts/build_direct_heat_source_utilisation_profiles.py" + + def solar_thermal_cutout(wildcards): c = config_provider("solar_thermal", "cutout")(wildcards) if c == "default": @@ -1004,6 +1062,20 @@ rule build_egs_potentials: "../scripts/build_egs_potentials.py" +def input_heat_source_potentials(w): + + return { + heat_source_name: resources( + "heat_source_potential_" + heat_source_name + "_base_s_{clusters}.csv" + ) + for heat_source_name in config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + )(w).keys() + if heat_source_name + in config_provider("sector", "heat_pump_sources", "urban central")(w) + } + + rule prepare_sector_network: params: time_resolution=config_provider("clustering", "temporal", "resolution_sector"), @@ -1028,8 +1100,15 @@ rule prepare_sector_network: heat_pump_sources=config_provider("sector", "heat_pump_sources"), heat_systems=config_provider("sector", "heat_systems"), energy_totals_year=config_provider("energy", "energy_totals_year"), + heat_utilisation_potentials=config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + ), + direct_utilisation_heat_sources=config_provider( + "sector", "district_heating", "direct_utilisation_heat_sources" + ), input: unpack(input_profile_offwind), + unpack(input_heat_source_potentials), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, snapshot_weightings=resources( @@ -1119,6 +1198,9 @@ rule prepare_sector_network: if config_provider("sector", "enhanced_geothermal", "enable")(w) else [] ), + direct_heat_source_utilisation_profiles=resources( + "direct_heat_source_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), output: RESULTS + "prenetworks/base_s_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", diff --git a/rules/retrieve.smk b/rules/retrieve.smk index a935159b4..9154386b1 100755 --- a/rules/retrieve.smk +++ b/rules/retrieve.smk @@ -631,3 +631,21 @@ if config["enable"]["retrieve"] and ( "data/osm-raw/{country}/substations_relation.json", country=config_provider("countries"), ), + + +if config["enable"]["retrieve"]: + + rule retrieve_heat_source_utilisation_potentials: + params: + heat_source="{heat_source}", + heat_utilisation_potentials=config_provider( + "sector", "district_heating", "heat_utilisation_potentials" + ), + log: + "logs/retrieve_heat_source_potentials_{heat_source}.log", + resources: + mem_mb=500, + output: + "data/heat_source_utilisation_potentials/{heat_source}.gpkg", + script: + "../scripts/retrieve_heat_source_utilisation_potentials.py" diff --git a/scripts/build_cop_profiles/CentralHeatingCopApproximator.py b/scripts/build_cop_profiles/CentralHeatingCopApproximator.py index 08dd6a1a8..30f84356b 100644 --- a/scripts/build_cop_profiles/CentralHeatingCopApproximator.py +++ b/scripts/build_cop_profiles/CentralHeatingCopApproximator.py @@ -144,11 +144,17 @@ def approximate_cop(self) -> Union[xr.DataArray, np.array]: """ Calculate the coefficient of performance (COP) for the system. + Notes: + ------ + Returns 0 where the source inlet temperature is greater than the sink outlet temperature. + Returns: -------- Union[xr.DataArray, np.array]: The calculated COP values. """ - return ( + return xr.where( + self.t_source_in_kelvin > self.t_sink_out_kelvin, + 0, self.ideal_lorenz_cop * ( ( @@ -170,7 +176,7 @@ def approximate_cop(self) -> Union[xr.DataArray, np.array]: * (1 - self.ratio_evaporation_compression_work) + 1 - self.isentropic_efficiency_compressor_kelvin - - self.heat_loss + - self.heat_loss, ) @property diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index b93f7df69..28bbf1a10 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: MIT """ Approximate heat pump coefficient-of-performance (COP) profiles for different -heat sources and systems. +heat sources and systems. Returns zero where source temperature higher than sink temperature. For central heating, this is based on Jensen et al. (2018) (c.f. `CentralHeatingCopApproximator `_) and for decentral heating, the approximation is based on Staffell et al. (2012) (c.f. `DecentralHeatingCopApproximator `_). @@ -27,11 +27,8 @@ urban central: urban decentral: rural: - snapshots: - Inputs ------ -- `resources//regions_onshore.geojson`: Onshore regions - `resources//temp_soil_total`: Ground temperature - `resources//temp_air_total`: Air temperature @@ -94,10 +91,6 @@ def get_cop( ).approximate_cop() -def get_country_from_node_name(node_name: str) -> str: - return node_name[:2] - - if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake @@ -109,9 +102,6 @@ def get_country_from_node_name(node_name: str) -> str: set_scenario_config(snakemake) - # map forward and return temperatures specified on country-level to onshore regions - regions_onshore = gpd.read_file(snakemake.input.regions_onshore)["name"] - snapshots = pd.date_range(freq="h", **snakemake.params.snapshots) central_heating_forward_temperature: xr.DataArray = xr.open_dataarray( snakemake.input.central_heating_forward_temperature_profiles ) @@ -123,9 +113,23 @@ def get_country_from_node_name(node_name: str) -> str: for heat_system_type, heat_sources in snakemake.params.heat_pump_sources.items(): cop_this_system_type = [] for heat_source in heat_sources: - source_inlet_temperature_celsius = xr.open_dataarray( - snakemake.input[f"temp_{heat_source.replace('ground', 'soil')}_total"] - ) + if heat_source in ["ground", "air"]: + source_inlet_temperature_celsius = xr.open_dataarray( + snakemake.input[ + f"temp_{heat_source.replace('ground', 'soil')}_total" + ] + ) + elif heat_source in snakemake.params.heat_utilisation_potentials.keys(): + source_inlet_temperature_celsius = ( + snakemake.params.heat_utilisation_potentials[heat_source][ + "constant_temperature_celsius" + ] + ) + else: + raise ValueError( + f"Unknown heat source {heat_source}. Must be one of [ground, air] or {snakemake.params.heat_sources.keys()}." + ) + cop_da = get_cop( heat_system_type=heat_system_type, heat_source=heat_source, diff --git a/scripts/build_direct_heat_source_utilisation_profiles.py b/scripts/build_direct_heat_source_utilisation_profiles.py new file mode 100644 index 000000000..72a9e4646 --- /dev/null +++ b/scripts/build_direct_heat_source_utilisation_profiles.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: : 2020-2024 The PyPSA-Eur Authors +# +# SPDX-License-Identifier: MIT +""" +Build availability profiles for direct heat source utilisation (1 in regions and time steps where heat source can be utilised, 0 otherwise). +When direct utilisation is possible, heat pump COPs are set to zero (c.f. `build_cop_profiles`). + +Relevant Settings +----------------- + +.. code:: yaml + sector: + district_heating: + heat_utilisation_potentials: + direct_utilisation_heat_sources: + snapshots: + +Inputs +------ +- `resources//central_heating_forward_temperatures_base_s_{clusters}_{planning_horizons}.nc`: Central heating forward temperature profiles + +Outputs +------- +- `resources//direct_heat_source_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc`: Direct heat source utilisation profiles +""" + +from typing import List + +import xarray as xr +from _helpers import set_scenario_config + + +def get_source_temperature(heat_source_key: str): + """ + Get the constant temperature of a heat source. + + Args: + ----- + heat_source_key: str + The key (name) of the heat source. + + Returns: + -------- + float + The constant temperature of the heat source in degrees Celsius. + + Raises: + ------- + ValueError + If the heat source is unknown (not in `config`). + """ + + if heat_source_key in snakemake.params.heat_utilisation_potentials.keys(): + return snakemake.params.heat_utilisation_potentials[heat_source_key][ + "constant_temperature_celsius" + ] + else: + raise ValueError( + f"Unknown heat source {heat_source_key}. Must be one of { + snakemake.params.heat_sources.keys()}." + ) + + +def get_profile( + source_temperature: float | xr.DataArray, forward_temperature: xr.DataArray +) -> xr.DataArray | float: + """ + Get the direct heat source utilisation profile. + + Args: + ----- + source_temperature: float | xr.DataArray + The constant temperature of the heat source in degrees Celsius. If `xarray`, indexed by `time` and `region`. If a float, it is broadcasted to the shape of `forward_temperature`. + forward_temperature: xr.DataArray + The central heating forward temperature profiles. If `xarray`, indexed by `time` and `region`. If a float, it is broadcasted to the shape of `return_temperature`. + + Returns: + -------- + xr.DataArray | float + The direct heat source utilisation profile. + + """ + return xr.where(source_temperature >= forward_temperature, 1.0, 0.0) + + +if __name__ == "__main__": + if "snakemake" not in globals(): + from _helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_cop_profiles", + clusters=48, + ) + + set_scenario_config(snakemake) + + direct_utilisation_heat_sources: List[str] = ( + snakemake.params.direct_utilisation_heat_sources + ) + + central_heating_forward_temperature: xr.DataArray = xr.open_dataarray( + snakemake.input.central_heating_forward_temperature_profiles + ) + + xr.concat( + [ + get_profile( + source_temperature=get_source_temperature(heat_source_key), + forward_temperature=central_heating_forward_temperature, + ).assign_coords(heat_source=heat_source_key) + for heat_source_key in direct_utilisation_heat_sources + ], + dim="heat_source", + ).to_netcdf(snakemake.output.direct_heat_source_utilisation_profiles) diff --git a/scripts/build_heat_source_potentials/onshore_region_data.py b/scripts/build_heat_source_potentials/onshore_region_data.py new file mode 100755 index 000000000..1dfcbf42d --- /dev/null +++ b/scripts/build_heat_source_potentials/onshore_region_data.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: : 2020-2024 The PyPSA-Eur Authors +# +# SPDX-License-Identifier: MIT +""" +Helper class for matching heat source potentials to onshore regions. +""" + +from typing import List + +import geopandas as gpd + + +class OnshoreRegionData: + """ + This class is used to map heat potentials to onshore regions. + + Attributes + ---------- + onshore_regions : gpd.GeoDataFrame + GeoDataFrame containing the onshore regions + data : gpd.GeoDataFrame + GeoDataFrame containing the heat potentials + scaling_factor : float + Scaling factor for the heat potentials + """ + + def __init__( + self, + onshore_regions: gpd.GeoDataFrame, + data: gpd.GeoDataFrame, + column_name: str, + scaling_factor: float = 1.0, + ) -> None: + """ + Parameters + ---------- + onshore_regions : gpd.GeoDataFrame + GeoDataFrame containing the onshore regions + data : gpd.GeoDataFrame + GeoDataFrame containing the heat potentials + column_name : str + Column name of the heat potential data in `data` + scaling_factor : float, optional + Scaling factor for the heat potentials, by default 1.0 + """ + + self.onshore_regions = onshore_regions + self.scaling_factor = scaling_factor + self.data = data.to_crs(onshore_regions.crs) + self._column_name = column_name + + self._mapped = False + + @property + def data_in_regions_scaled(self) -> gpd.GeoDataFrame: + """ + Scale the heat potentials and map them to the onshore regions. + + Returns + ------- + gpd.GeoDataFrame + GeoDataFrame containing the scaled heat potentials in the onshore regions + """ + if self._mapped: + return self._scaled_data_in_regions + else: + self._data_in_regions = self._map_to_onshore_regions() + self._mapped = True + return self._scaled_data_in_regions + + def _map_to_onshore_regions(self): + """ + Map the heat potentials to the onshore regions + """ + data_in_regions = gpd.sjoin(self.data, self.onshore_regions, how="right") + + # Initialize an empty list to store the merged GeoDataFrames + ret_val = self.onshore_regions.copy() + ret_val[self._column_name] = ( + data_in_regions.groupby("name")[self._column_name] + .sum() + .reset_index(drop=True) + ) + ret_val = ret_val.set_index("name", drop=True).rename_axis("name")[ + self._column_name + ] + + return ret_val + + @property + def _scaled_data_in_regions(self): + """ + Scale the heat potentials in the onshore regions + """ + return self._data_in_regions * self.scaling_factor diff --git a/scripts/build_heat_source_potentials/run.py b/scripts/build_heat_source_potentials/run.py new file mode 100644 index 000000000..b824b613f --- /dev/null +++ b/scripts/build_heat_source_potentials/run.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: : 2020-2024 The PyPSA-Eur Authors +# +# SPDX-License-Identifier: MIT +""" +Build heat source potentials for a given heat source. + +This script maps and aggregates heat source potentials per heat source to `onshore_regions` using `OnshoreRegionData`. +It scales the heat source utilisation potentials to technical potentials by dividing the utilisation potentials by the full load hours of the heat source, also taking into account the energy unit set for the respective source in the config. + + +Relevant Settings +----------------- +.. code:: yaml + sector: + district_heating: + heat_utilisation_potentials: + {heat_source} + + +Inputs +------ +- `resources//regions_onshore.geojson` +- `resources//heat_source_utilisation_potentials/.gpkg` + +Outputs +------- +- `resources//heat_source_technical_potential_{heat_source}_base_s_{clusters}.csv` +""" + +import geopandas as gpd +from _helpers import set_scenario_config + +from scripts.build_heat_source_potentials.onshore_region_data import OnshoreRegionData + + +def get_unit_conversion_factor( + input_unit: str, + output_unit: str, + unit_scaling: dict = {"Wh": 1, "kWh": 1e3, "MWh": 1e6, "GWh": 1e9, "TWh": 1e12}, +) -> float: + + if input_unit not in unit_scaling.keys(): + raise ValueError( + f"Input unit {input_unit} not allowed. Must be one of { + unit_scaling.keys()}" + ) + elif output_unit not in unit_scaling.keys(): + raise ValueError( + f"Output unit {output_unit} not allowed. Must be one of { + unit_scaling.keys()}" + ) + + return unit_scaling[input_unit] / unit_scaling[output_unit] + + +if __name__ == "__main__": + + if "snakemake" not in globals(): + from _helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_heat_source_potentials", + clusters=48, + ) + + set_scenario_config(snakemake) + + regions_onshore = gpd.read_file(snakemake.input.regions_onshore) + heat_source_utilisation_potential = gpd.read_file( + snakemake.input.utilisation_potential + ) + + unit_conversion_factor = get_unit_conversion_factor( + input_unit=snakemake.params.heat_utilisation_potentials[ + snakemake.wildcards.heat_source + ]["unit"], + output_unit="MWh", + ) + scaling_factor = ( + unit_conversion_factor + / snakemake.params.heat_utilisation_potentials[snakemake.wildcards.heat_source][ + "full_load_hours" + ] + ) + + heat_source_technical_potential = OnshoreRegionData( + onshore_regions=regions_onshore, + data=heat_source_utilisation_potential, + column_name=snakemake.params.heat_utilisation_potentials[ + snakemake.wildcards.heat_source + ]["column_name"], + scaling_factor=scaling_factor, + ).data_in_regions_scaled + + heat_source_technical_potential.to_csv(snakemake.output[0]) diff --git a/scripts/definitions/heat_system.py b/scripts/definitions/heat_system.py index 2806f6bf4..127a991b6 100644 --- a/scripts/definitions/heat_system.py +++ b/scripts/definitions/heat_system.py @@ -226,6 +226,24 @@ def heat_pump_costs_name(self, heat_source: str) -> str: """ return f"{self.central_or_decentral} {heat_source}-sourced heat pump" + def heat_source_costs_name(self, heat_source: str) -> str: + """ + Generates the name for direct source utilisation costs based on the heat source and + system. + Used to retrieve data from `technology-data `. + + Parameters + ---------- + heat_source : str + The heat source. + + Returns + ------- + str + The name for the technology-data costs. + """ + return f"{self.central_or_decentral} {heat_source} heat source" + @property def resistive_heater_costs_name(self) -> str: """ diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b747e30ba..1709e8201 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2030,7 +2030,12 @@ def build_heat_demand(n): return heat_demand -def add_heat(n: pypsa.Network, costs: pd.DataFrame, cop: xr.DataArray): +def add_heat( + n: pypsa.Network, + costs: pd.DataFrame, + cop: xr.DataArray, + direct_heat_source_utilisation_profile: xr.DataArray, +): """ Add heat sector to the network. @@ -2147,8 +2152,8 @@ def add_heat(n: pypsa.Network, costs: pd.DataFrame, cop: xr.DataArray): for heat_source in snakemake.params.heat_pump_sources[ heat_system.system_type.value ]: - costs_name = heat_system.heat_pump_costs_name(heat_source) - efficiency = ( + costs_name_heat_pump = heat_system.heat_pump_costs_name(heat_source) + cop_heat_pump = ( cop.sel( heat_system=heat_system.system_type.value, heat_source=heat_source, @@ -2157,23 +2162,106 @@ def add_heat(n: pypsa.Network, costs: pd.DataFrame, cop: xr.DataArray): .to_pandas() .reindex(index=n.snapshots) if options["time_dep_hp_cop"] - else costs.at[costs_name, "efficiency"] + else costs.at[costs_name_heat_pump, "efficiency"] ) - n.add( - "Link", - nodes, - suffix=f" {heat_system} {heat_source} heat pump", - bus0=nodes, - bus1=nodes + f" {heat_system} heat", - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=efficiency, - capital_cost=costs.at[costs_name, "efficiency"] - * costs.at[costs_name, "fixed"] - * overdim_factor, - p_nom_extendable=True, - lifetime=costs.at[costs_name, "lifetime"], - ) + if heat_source in snakemake.params.heat_utilisation_potentials: + # get potential + p_max_source = pd.read_csv( + snakemake.input[heat_source], + index_col=0, + ).squeeze()[nodes] + + # add resource + heat_carrier = f"{heat_system} {heat_source} heat" + n.add("Carrier", heat_carrier) + n.madd( + "Bus", + nodes, + suffix=f" {heat_carrier}", + carrier=heat_carrier, + ) + + costs_name_heat_source = heat_system.heat_source_costs_name(heat_source) + if heat_source in snakemake.params.direct_utilisation_heat_sources: + capital_cost = ( + costs.at[ + heat_system.heat_source_costs_name(heat_source), "fixed" + ] + * overdim_factor + ) + lifetime = costs.at[ + heat_system.heat_source_costs_name(heat_source), "lifetime" + ] + else: + capital_cost = 0.0 + lifetime = np.inf + n.madd( + "Generator", + nodes, + suffix=f" {heat_carrier}", + bus=nodes + f" {heat_carrier}", + carrier=heat_carrier, + p_nom_extendable=True, + capital_cost=capital_cost, + lifetime=lifetime, + p_nom_max=p_max_source, + ) + + # add heat pump converting source heat + electricity to urban central heat + n.madd( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat pump", + bus0=nodes, + bus1=nodes + f" {heat_carrier}", + bus2=nodes + f" {heat_system} heat", + carrier=f"{heat_system} {heat_source} heat pump", + efficiency=-(cop_heat_pump - 1), + efficiency2=cop_heat_pump, + capital_cost=costs.at[costs_name_heat_pump, "efficiency"] + * costs.at[costs_name_heat_pump, "fixed"] + * overdim_factor, + p_nom_extendable=True, + lifetime=costs.at[costs_name_heat_pump, "lifetime"], + ) + + if heat_source in snakemake.params.direct_utilisation_heat_sources: + # 1 if source temperature exceeds forward temperature, 0 otherwise: + efficiency_direct_utilisation = ( + direct_heat_source_utilisation_profile.sel( + heat_source=heat_source, + name=nodes, + ) + .to_pandas() + .reindex(index=n.snapshots) + ) + # add link for direct usage of heat source when source temperature exceeds forward temperature + n.madd( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat direct utilisation", + bus0=nodes + f" {heat_carrier}", + bus1=nodes + f" {heat_system} heat", + efficiency=efficiency_direct_utilisation, + carrier=f"{heat_system} {heat_source} heat direct utilisation", + p_nom_extendable=True, + ) + else: + n.madd( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat pump", + bus0=nodes, + bus1=nodes + f" {heat_system} heat", + carrier=f"{heat_system} {heat_source} heat pump", + efficiency=cop_heat_pump, + capital_cost=costs.at[costs_name_heat_pump, "efficiency"] + * costs.at[costs_name_heat_pump, "fixed"] + * overdim_factor, + p_nom_extendable=True, + lifetime=costs.at[costs_name_heat_pump, "lifetime"], + ) if options["tes"]: n.add("Carrier", f"{heat_system} water tanks") @@ -4583,7 +4671,14 @@ def add_enhanced_geothermal(n, egs_potentials, egs_overlap, costs): add_land_transport(n, costs) if options["heating"]: - add_heat(n=n, costs=costs, cop=xr.open_dataarray(snakemake.input.cop_profiles)) + add_heat( + n=n, + costs=costs, + cop=xr.open_dataarray(snakemake.input.cop_profiles), + direct_heat_source_utilisation_profile=xr.open_dataarray( + snakemake.input.direct_heat_source_utilisation_profiles + ), + ) if options["biomass"]: add_biomass(n, costs) diff --git a/scripts/retrieve_heat_source_utilisation_potentials.py b/scripts/retrieve_heat_source_utilisation_potentials.py new file mode 100644 index 000000000..59e662d8b --- /dev/null +++ b/scripts/retrieve_heat_source_utilisation_potentials.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: : 2024 The PyPSA-Eur Authors +# +# SPDX-License-Identifier: MIT +""" +Retrieve heat source utilisation potentials from Fraunhofer Fordatis. + +Source +------ +Manz et al. 2024: "Spatial analysis of renewable and excess heat potentials for climate-neutral district heating in Europe", Renewable Energy, vol. 224, no. 120111, https://doi.org/10.1016/j.renene.2024.120111 + +Relevant Settings +----------------- +.. code:: yaml + sector: + district_heating: + heat_utilisation_potentials: + +Outputs +------ +- `resources//heat_source_utilisation_potentials/.gpkg` +""" + +import logging +from pathlib import Path + +from _helpers import configure_logging, progress_retrieve, set_scenario_config + +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + if "snakemake" not in globals(): + from _helpers import mock_snakemake + + snakemake = mock_snakemake("retrieve_heat_source_utilisation_potentials") + rootpath = ".." + else: + rootpath = "." + configure_logging(snakemake) + set_scenario_config(snakemake) + + # license: https://creativecommons.org/licenses/by/4.0/ + # download the data in url + heat_source = snakemake.params["heat_source"] + filepath = Path(snakemake.output[0]) + if not filepath.parent.exists(): + filepath.parent.mkdir(parents=True) + + url = f"https://fordatis.fraunhofer.de/bitstream/fordatis/341.3/10/{snakemake.params.heat_utilisation_potentials[heat_source]['key']}.gpkg" + + logger.info( + f"Downloading heat source utilisation potential data for {heat_source} from '{url}'." + ) + disable_progress = snakemake.config["run"].get("disable_progressbar", False) + progress_retrieve(url, filepath, disable=disable_progress) + + logger.info(f"Data available at at {filepath}")