diff --git a/Snakefile b/Snakefile index 337f59e8a7..de393c6b1d 100644 --- a/Snakefile +++ b/Snakefile @@ -159,7 +159,7 @@ rule all: + "maps/static/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-heat_source_temperature_map_river_water.html" if config_provider("plotting", "enable_heat_source_maps")(w) and "river_water" - in config_provider("sector", "heat_pump_sources", "urban central")(w) + in config_provider("sector", "heat_sources", "urban central")(w) else [] ), **config["scenario"], @@ -171,7 +171,7 @@ rule all: + "maps/static/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-heat_source_temperature_map_sea_water.html" if config_provider("plotting", "enable_heat_source_maps")(w) and "sea_water" - in config_provider("sector", "heat_pump_sources", "urban central")(w) + in config_provider("sector", "heat_sources", "urban central")(w) else [] ), **config["scenario"], @@ -183,7 +183,7 @@ rule all: + "maps/static/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-heat_source_temperature_map_ambient_air.html" if config_provider("plotting", "enable_heat_source_maps")(w) and "air" - in config_provider("sector", "heat_pump_sources", "urban central")(w) + in config_provider("sector", "heat_sources", "urban central")(w) else [] ), **config["scenario"], @@ -196,7 +196,7 @@ rule all: + "maps/static/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-heat_source_energy_map_river_water.html" if config_provider("plotting", "enable_heat_source_maps")(w) and "river_water" - in config_provider("sector", "heat_pump_sources", "urban central")(w) + in config_provider("sector", "heat_sources", "urban central")(w) else [] ), **config["scenario"], diff --git a/config/config.default.yaml b/config/config.default.yaml index 739cec2263..77ea4bf68a 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -658,12 +658,14 @@ sector: rolling_window_ambient_temperature: 72 relative_annual_temperature_reduction: 0.01 ptes: - dynamic_capacity: false - supplemental_heating: - enable: false - booster_heat_pump: false - max_top_temperature: 90 - min_bottom_temperature: 35 + enable: true + temperature_dependent_capacity: false + charge_boosting_required: false + discharge_resistive_boosting: false + top_temperature: 90 + bottom_temperature: 35 + design_top_temperature: 90 + design_bottom_temperature: 35 ates: enable: false suitable_aquifer_types: @@ -682,22 +684,16 @@ sector: isentropic_compressor_efficiency: 0.8 heat_loss: 0.0 min_delta_t_lift: 10 - limited_heat_sources: - geothermal: - constant_temperature_celsius: 65 - ignore_missing_regions: false - river_water: - constant_temperature_celsius: false - direct_utilisation_heat_sources: - - geothermal - temperature_limited_stores: - - ptes dh_areas: buffer: 1000 handle_missing_countries: fill - heat_pump_sources: + geothermal: + constant_temperature_celsius: 65 + heat_sources: urban central: - air + - ptes + - geothermal urban decentral: - air rural: @@ -816,7 +812,7 @@ sector: annualise_cost: true tax_weighting: false construction_index: true - tes: true + ttes: true boilers: true resistive_heaters: true oil_boilers: false diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index a2d7405123..7eb87d19cb 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -608,11 +608,14 @@ plotting: services rural air heat pump: '#5af95d' urban central air heat pump: '#6cfb6b' ptes heat pump: '#5dade2' - urban central ptes heat pump: '#3498db' + ptes heat preheater: '#c3d9e8' + ptes heat utilisation: '#2013d1' urban central geothermal heat pump: '#4f2144' geothermal heat pump: '#4f2144' - geothermal heat direct utilisation: '#ba91b1' + geothermal heat utilisation: '#ba91b1' river_water heat: '#4bb9f2' + river_water heat preheater: '#4bb9f2' + river_water heat utilisation: '#4bb9f2' river_water heat pump: '#4bb9f2' sea_water heat: '#0b222e' sea_water heat pump: '#0b222e' @@ -629,6 +632,8 @@ plotting: CHP electric: '#8a5751' district heating: '#e8beac' resistive heater: '#d8f9b8' + resistive heat stand-alone: '#a7f759' + water pits resistive booster: '#a4bf8a' residential rural resistive heater: '#bef5b5' residential urban decentral resistive heater: '#b2f1a9' services rural resistive heater: '#a5ed9d' @@ -717,8 +722,10 @@ plotting: other: '#000000' geothermal: '#ba91b1' geothermal heat: '#ba91b1' + geothermal heat preheater: '#f2bbe6' geothermal district heat: '#d19D00' geothermal organic rankine cycle: '#ffbf00' + urban central geothermal heat pre-chilled: '#ba91b1' AC: "#70af1d" AC-AC: "#70af1d" AC line: "#70af1d" diff --git a/config/test/config.myopic.yaml b/config/test/config.myopic.yaml index 86887bec6b..74853c8d14 100644 --- a/config/test/config.myopic.yaml +++ b/config/test/config.myopic.yaml @@ -36,16 +36,13 @@ sector: district_heating: ates: enable: true - ptes: - supplemental_heating: - enable: true - booster_heat_pump: true - heat_pump_sources: + heat_sources: urban central: - air - geothermal - river_water - sea_water + - ptes urban decentral: - air rural: diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index af763f070f..809eeb6355 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -74,18 +74,15 @@ sector: - 10 - 21 district_heating: - ptes: - supplemental_heating: - enable: true - booster_heat_pump: true ates: enable: true - heat_pump_sources: + heat_sources: urban central: - air - geothermal - river_water - sea_water + - ptes urban decentral: - air rural: diff --git a/config/test/config.perfect.yaml b/config/test/config.perfect.yaml index 16a5199ad3..09e5ef2be3 100644 --- a/config/test/config.perfect.yaml +++ b/config/test/config.perfect.yaml @@ -45,19 +45,16 @@ sector: solid_biomass_import: enable: true district_heating: - ptes: - supplemental_heating: - enable: true - booster_heat_pump: true ates: enable: true - heat_pump_sources: + heat_sources: urban central: - air - ptes - river_water - sea_water - geothermal + - ptes urban decentral: - air rural: diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 28cf11ecaf..e9962403a1 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -9,6 +9,7 @@ Release Notes .. Upcoming Release .. ================= +* Update heat source handling in `prepare_sector_network` and introduce preheating of heat sources for more realistic system integrations (https://github.com/PyPSA/pypsa-eur/pull/1893). PyPSA-Eur v2026.02.0 (18th February 2026) ========================================= diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 72c7756aa5..b446001b78 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -326,7 +326,6 @@ rule build_geothermal_heat_potential: constant_temperature_celsius=config_provider( "sector", "district_heating", - "limited_heat_sources", "geothermal", "constant_temperature_celsius", ), @@ -337,22 +336,35 @@ rule build_geothermal_heat_potential: "geothermal", "ignore_missing_regions", ), + heat_source_cooling=config_provider( + "sector", "district_heating", "heat_source_cooling" + ), input: isi_heat_potentials=rules.retrieve_geothermal_heat_utilisation_potentials.output[ "isi_heat_potentials" ], regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"), + central_heating_forward_temperature_profiles=resources( + "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), + central_heating_return_temperature_profiles=resources( + "central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), lau_regions=rules.retrieve_lau_regions.output["zip"], output: heat_source_power=resources( - "heat_source_power_geothermal_base_s_{clusters}.csv" + "heat_source_power_geothermal_base_s_{clusters}_{planning_horizons}.csv" ), resources: mem_mb=2000, log: - logs("build_heat_source_potentials_geothermal_s_{clusters}.log"), + logs( + "build_heat_source_potentials_geothermal_s_{clusters}_{planning_horizons}.log" + ), benchmark: - benchmarks("build_heat_source_potentials/geothermal_s_{clusters}") + benchmarks( + "build_heat_source_potentials/geothermal_s_{clusters}_{planning_horizons}" + ) script: scripts("build_geothermal_heat_potential.py") @@ -538,47 +550,62 @@ def input_heat_source_temperature( temperature sources). """ - heat_pump_sources = set( - config_provider("sector", "heat_pump_sources", "urban central")(w) + heat_sources = set( + config_provider("sector", "heat_sources", "urban central")(w) ).union( - config_provider("sector", "heat_pump_sources", "urban decentral")(w), - config_provider("sector", "heat_pump_sources", "rural")(w), + config_provider("sector", "heat_sources", "urban decentral")(w), + config_provider("sector", "heat_sources", "rural")(w), ) - is_limited_heat_source = { - heat_source_name: heat_source_name - in config_provider("sector", "district_heating", "limited_heat_sources")(w) - for heat_source_name in heat_pump_sources - } + district_heating_config = config_provider("sector", "district_heating")(w) - has_constant_temperature = { - heat_source_name: ( - False - if not is_limited_heat_source[heat_source_name] - else config_provider( - "sector", - "district_heating", - "limited_heat_sources", - heat_source_name, - "constant_temperature_celsius", - )(w) - ) - for heat_source_name in heat_pump_sources - } + file_names = {} + for heat_source in heat_sources: + if ( + district_heating_config + and heat_source in district_heating_config + and district_heating_config[heat_source].get( + "constant_temperature_celsius", False + ) + ): + continue + if heat_source == "ptes": + file_names[f"temp_{heat_source}"] = resources( + f"temp_{replace_names.get(heat_source, heat_source)}_base_s_{{clusters}}_{{planning_horizons}}.nc" + ) + else: + file_names[f"temp_{heat_source}"] = resources( + f"temp_{replace_names.get(heat_source, heat_source)}_base_s_{{clusters}}.nc" + ) + return file_names - # replace names for soil and air temperature files - return { - f"temp_{heat_source_name}": resources( - "temp_" - + replace_names.get(heat_source_name, heat_source_name) - + "_base_s_{clusters}" - + ("_{planning_horizons}" if heat_source_name == "ptes" else "") - + ".nc" - ) - for heat_source_name in heat_pump_sources - # remove heat sources with constant temperature - i.e. no temperature profile file - if not has_constant_temperature[heat_source_name] - } + +def input_ptes_bottom_temperature(w) -> dict[str, str]: + """ + Generate conditional input for PTES bottom temperature profiles. + + Only includes the input file if PTES is configured as a heat source + for urban central heating. + + Parameters + ---------- + w : snakemake.io.Wildcards + Snakemake wildcards object. + + Returns + ------- + dict[str, str] + Dictionary with "temp_ptes_bottom" key if PTES is a heat source, + empty dict otherwise. + """ + heat_sources = config_provider("sector", "heat_sources", "urban central")(w) + if "ptes" in heat_sources: + return { + "temp_ptes_bottom": resources( + "temp_ptes_bottom_profiles_base_s_{clusters}_{planning_horizons}.nc" + ) + } + return {} def input_seawater_temperature(w) -> dict[str, str]: @@ -652,9 +679,12 @@ rule build_cop_profiles: heat_pump_cop_approximation_central_heating=config_provider( "sector", "district_heating", "heat_pump_cop_approximation" ), - heat_pump_sources=config_provider("sector", "heat_pump_sources"), - limited_heat_sources=config_provider( - "sector", "district_heating", "limited_heat_sources" + heat_sources=config_provider("sector", "heat_sources"), + constant_temperature_geothermal=config_provider( + "sector", + "district_heating", + "geothermal", + "constant_temperature_celsius", ), snapshots=config_provider("snapshots"), input: @@ -682,19 +712,40 @@ rule build_ptes_operations: message: "Building thermal energy storage operations profiles for {wildcards.clusters} clusters and {wildcards.planning_horizons} planning horizon" params: - max_ptes_top_temperature=config_provider( + top_temperature=config_provider( "sector", "district_heating", "ptes", - "max_top_temperature", + "top_temperature", ), - min_ptes_bottom_temperature=config_provider( + bottom_temperature=config_provider( "sector", "district_heating", "ptes", - "min_bottom_temperature", + "bottom_temperature", ), snapshots=config_provider("snapshots"), + charge_boosting_required=config_provider( + "sector", "district_heating", "ptes", "charge_boosting_required" + ), + discharge_resistive_boosting=config_provider( + "sector", "district_heating", "ptes", "discharge_resistive_boosting" + ), + temperature_dependent_capacity=config_provider( + "sector", "district_heating", "ptes", "temperature_dependent_capacity" + ), + design_top_temperature=config_provider( + "sector", + "district_heating", + "ptes", + "design_top_temperature", + ), + design_bottom_temperature=config_provider( + "sector", + "district_heating", + "ptes", + "design_bottom_temperature", + ), input: central_heating_forward_temperature_profiles=resources( "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" @@ -704,15 +755,18 @@ rule build_ptes_operations: ), regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"), output: - ptes_direct_utilisation_profiles=resources( - "ptes_direct_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" - ), ptes_top_temperature_profiles=resources( "temp_ptes_top_profiles_base_s_{clusters}_{planning_horizons}.nc" ), + ptes_bottom_temperature_profiles=resources( + "temp_ptes_bottom_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), ptes_e_max_pu_profiles=resources( "ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc" ), + ptes_boost_per_discharge_profiles=resources( + "ptes_boost_per_discharge_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), resources: mem_mb=2000, log: @@ -727,33 +781,46 @@ rule build_direct_heat_source_utilisation_profiles: message: "Building direct heat source utilization profiles for industrial applications for {wildcards.clusters} clusters and {wildcards.planning_horizons} planning horizon" params: - direct_utilisation_heat_sources=config_provider( - "sector", "district_heating", "direct_utilisation_heat_sources" - ), - limited_heat_sources=config_provider( - "sector", "district_heating", "limited_heat_sources" + heat_sources=config_provider("sector", "heat_sources", "urban central"), + heat_source_cooling=config_provider( + "sector", "district_heating", "heat_source_cooling" ), snapshots=config_provider("snapshots"), + constant_temperature_geothermal=config_provider( + "sector", + "district_heating", + "geothermal", + "constant_temperature_celsius", + ), + ptes_enable=config_provider("sector", "district_heating", "ptes", "enable"), input: + unpack(input_heat_source_temperature), + unpack(input_ptes_bottom_temperature), central_heating_forward_temperature_profiles=resources( "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" ), + central_heating_return_temperature_profiles=resources( + "central_heating_return_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" + heat_source_direct_utilisation_profiles=resources( + "heat_source_direct_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + ), + heat_source_preheater_utilisation_profiles=resources( + "heat_source_preheater_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" + "build_heat_source_utilisation_profiles_s_{clusters}_{planning_horizons}.log" ), benchmark: benchmarks( - "build_direct_heat_source_utilisation_profiles/s_{clusters}_{planning_horizons}" + "build_heat_source_utilisation_profiles/s_{clusters}_{planning_horizons}" ) script: - scripts("build_direct_heat_source_utilisation_profiles.py") + scripts("build_heat_source_utilisation_profiles.py") rule build_solar_thermal_profiles: @@ -1548,18 +1615,27 @@ rule build_egs_potentials: def input_heat_source_power(w): - return { - heat_source_name: resources( - "heat_source_power_" + heat_source_name + "_base_s_{clusters}.csv" - ) - for heat_source_name in config_provider( - "sector", "heat_pump_sources", "urban central" - )(w) - if heat_source_name - in config_provider("sector", "district_heating", "limited_heat_sources")( - w - ).keys() - } + from scripts.definitions.heat_source import HeatSource + + result = {} + heat_sources = config_provider("sector", "heat_sources", "urban central")(w) + + for heat_source_name in heat_sources: + if HeatSource(heat_source_name).requires_generator: + if HeatSource(heat_source_name) == HeatSource.GEOTHERMAL: + result[heat_source_name] = resources( + "heat_source_power_" + + heat_source_name + + "_base_s_{clusters}_{planning_horizons}.csv" + ) + else: + result[heat_source_name] = resources( + "heat_source_power_" + heat_source_name + "_base_s_{clusters}.csv" + ) + else: + continue + + return result rule prepare_sector_network: @@ -1586,18 +1662,8 @@ rule prepare_sector_network: electricity=config_provider("electricity"), biomass=config_provider("biomass"), RDIR=RDIR, - heat_pump_sources=config_provider("sector", "heat_pump_sources"), - heat_systems=config_provider("sector", "heat_systems"), + heat_sources=config_provider("sector", "heat_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), - direct_utilisation_heat_sources=config_provider( - "sector", "district_heating", "direct_utilisation_heat_sources" - ), - limited_heat_sources=config_provider( - "sector", "district_heating", "limited_heat_sources" - ), - temperature_limited_stores=config_provider( - "sector", "district_heating", "temperature_limited_stores" - ), input: unpack(input_profile_offwind), unpack(input_heat_source_power), @@ -1677,20 +1743,36 @@ rule prepare_sector_network: resources( "ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc" ) - if config_provider( - "sector", "district_heating", "ptes", "dynamic_capacity" + if config_provider("sector", "district_heating", "ptes", "enable")(w) + and config_provider( + "sector", "district_heating", "ptes", "temperature_dependent_capacity" )(w) else [] ), - ptes_direct_utilisation_profiles=lambda w: ( + ptes_boost_per_discharge_profiles=lambda w: ( resources( - "ptes_direct_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + "ptes_boost_per_discharge_profiles_base_s_{clusters}_{planning_horizons}.nc" ) - if config_provider( - "sector", "district_heating", "ptes", "supplemental_heating", "enable" + if config_provider("sector", "district_heating", "ptes", "enable")(w) + and config_provider( + "sector", "district_heating", "ptes", "discharge_resistive_boosting" )(w) else [] ), + heat_source_direct_utilisation_profiles=lambda w: ( + resources( + "heat_source_direct_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + ) + if len(config_provider("sector", "heat_sources", "urban central")(w)) > 0 + else [] + ), + heat_source_preheater_utilisation_profiles=lambda w: ( + resources( + "heat_source_preheater_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc" + ) + if len(config_provider("sector", "heat_sources", "urban central")(w)) > 0 + else [] + ), solar_thermal_total=lambda w: ( resources("solar_thermal_total_base_s_{clusters}.nc") if config_provider("sector", "solar_thermal")(w) @@ -1716,9 +1798,6 @@ 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" - ), ates_potentials=lambda w: ( resources("ates_potentials_base_s_{clusters}_{planning_horizons}.csv") if config_provider("sector", "district_heating", "ates", "enable")(w) diff --git a/rules/postprocess.smk b/rules/postprocess.smk index 6fe61cd1a8..119cd333c0 100644 --- a/rules/postprocess.smk +++ b/rules/postprocess.smk @@ -177,7 +177,7 @@ if config["foresight"] != "perfect": rule plot_heat_source_map: params: plotting=config_provider("plotting"), - heat_sources=config_provider("sector", "heat_pump_sources"), + heat_sources=config_provider("sector", "heat_sources"), input: regions=resources("regions_onshore_base_s_{clusters}.geojson"), heat_source_temperature=lambda w: ( diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index fb50153252..3a7835fb50 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -12,7 +12,7 @@ rule add_existing_baseyear: existing_capacities=config_provider("existing_capacities"), carriers=config_provider("electricity", "renewable_carriers"), costs=config_provider("costs"), - heat_pump_sources=config_provider("sector", "heat_pump_sources"), + heat_sources=config_provider("sector", "heat_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), input: network=resources( @@ -72,7 +72,8 @@ rule add_brownfield: drop_leap_day=config_provider("enable", "drop_leap_day"), carriers=config_provider("electricity", "renewable_carriers"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), - tes=config_provider("sector", "tes"), + ttes=config_provider("sector", "ttes"), + ptes=config_provider("sector", "district_heating", "ptes"), dynamic_ptes_capacity=config_provider( "sector", "district_heating", "ptes", "dynamic_capacity" ), diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index f4e5a73c08..c0a6b2bd5d 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -10,7 +10,7 @@ rule add_existing_baseyear: existing_capacities=config_provider("existing_capacities"), carriers=config_provider("electricity", "renewable_carriers"), costs=config_provider("costs"), - heat_pump_sources=config_provider("sector", "heat_pump_sources"), + heat_sources=config_provider("sector", "heat_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), input: network=resources( diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index c8871070fc..366d5eb8ae 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -367,7 +367,7 @@ def update_dynamic_ptes_capacity( update_heat_pump_efficiency(n, n_p, year) - if snakemake.params.tes and snakemake.params.dynamic_ptes_capacity: + if snakemake.params.ptes and snakemake.params.dynamic_ptes_capacity: update_dynamic_ptes_capacity(n, n_p, year) add_brownfield( diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index 7e03eccb9d..4f0027070f 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -26,6 +26,7 @@ ) from scripts.add_electricity import sanitize_carriers from scripts.build_energy_totals import cartesian +from scripts.definitions.heat_source import HeatSource from scripts.definitions.heat_system import HeatSystem from scripts.prepare_sector_network import cluster_heat_buses, define_spatial @@ -494,7 +495,7 @@ def add_heating_capacities_installed_before_baseyear( grouping_years: list[int], existing_capacities: pd.DataFrame, heat_pump_cop: xr.DataArray, - heat_pump_source_types: dict[str, list[str]], + heat_sources: dict[str, list[str]], efficiency_file: str, use_time_dependent_cop: bool, default_lifetime: int, @@ -523,8 +524,8 @@ def add_heating_capacities_installed_before_baseyear( Default lifetime for heating systems existing_capacities : pd.DataFrame Existing heating capacity distribution - heat_pump_source_types : dict - Heat pump sources by system type + heat_sources : dict + Heat sources by system type efficiency_file : str Path to heating efficiencies file energy_totals_year : int @@ -592,40 +593,55 @@ def add_heating_capacities_installed_before_baseyear( for ratio, grouping_year in zip(ratios, valid_grouping_years): # Add heat pumps - for heat_source in heat_pump_source_types[heat_system.system_type.value]: - costs_name = heat_system.heat_pump_costs_name(heat_source) - - efficiency = ( - heat_pump_cop.sel( - heat_system=heat_system.system_type.value, - heat_source=heat_source, - name=nodes, - ) - .to_pandas() - .reindex(index=n.snapshots) - if use_time_dependent_cop - else costs.at[costs_name, "efficiency"] - ) - - n.add( - "Link", - nodes, - suffix=f" {heat_system} {heat_source} heat pump-{grouping_year}", - bus0=nodes + " " + heat_system.value + " heat", - bus1=nodes_elec, - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=1 / efficiency.clip(lower=0.001), - capital_cost=costs.at[costs_name, "capital_cost"], - p_nom=existing_capacities.loc[ + for heat_source in heat_sources[heat_system.system_type.value]: + p_nom = ( + existing_capacities.loc[ nodes, (heat_system.value, f"{heat_source} heat pump") ] - * ratio, - p_max_pu=0, - p_min_pu=-1 * efficiency / efficiency.clip(lower=0.001), - build_year=int(grouping_year), - lifetime=costs.at[costs_name, "lifetime"], + * ratio ) + if p_nom.sum() > 0: + heat_source = HeatSource(heat_source) + + if heat_source not in [HeatSource.AIR, HeatSource.GROUND]: + raise ValueError( + f"Currently, only air-sourced and ground-sourced heat pumps are supported for baseyear capacities. Heat source {heat_source} is not." + ) + + costs_name = heat_system.heat_pump_costs_name(heat_source) + + efficiency = ( + heat_pump_cop.sel( + heat_system=heat_system.system_type.value, + heat_source=heat_source.value, + name=nodes, + ) + .to_pandas() + .reindex(index=n.snapshots) + if use_time_dependent_cop + else costs.at[costs_name, "efficiency"] + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat pump-{grouping_year}", + bus0=nodes + " " + heat_system.value + " heat", + bus1=nodes_elec, + carrier=f"{heat_system} {heat_source} heat pump", + efficiency=1 / efficiency.clip(lower=0.001), + capital_cost=costs.at[costs_name, "capital_cost"], + p_nom=existing_capacities.loc[ + nodes, (heat_system.value, f"{heat_source} heat pump") + ] + * ratio, + p_max_pu=0, + p_min_pu=-1 * efficiency / efficiency.clip(lower=0.001), + build_year=int(grouping_year), + lifetime=costs.at[costs_name, "lifetime"], + ) + # add resistive heater, gas boilers and oil boilers n.add( "Link", @@ -827,7 +843,7 @@ def add_heating_capacities_installed_before_baseyear( header=[0, 1], index_col=0, ), - heat_pump_source_types=snakemake.params.heat_pump_sources, + heat_sources=snakemake.params.heat_sources, efficiency_file=snakemake.input.heating_efficiencies, energy_totals_year=snakemake.params["energy_totals_year"], capacity_threshold=snakemake.params.existing_capacities[ diff --git a/scripts/build_cop_profiles/base_cop_approximator.py b/scripts/build_cop_profiles/base_cop_approximator.py index 95436403ff..13c9099247 100644 --- a/scripts/build_cop_profiles/base_cop_approximator.py +++ b/scripts/build_cop_profiles/base_cop_approximator.py @@ -123,10 +123,12 @@ def logarithmic_mean( Union[float, xr.DataArray, np.ndarray] Logarithmic mean temperature difference. """ - if (np.asarray(t_hot < t_cold)).any(): - raise ValueError("t_hot must be greater than t_cold") return xr.where( - t_hot == t_cold, - t_hot, - (t_hot - t_cold) / np.log(t_hot / t_cold), + t_hot < t_cold, + np.nan, + xr.where( + t_hot == t_cold, + t_hot, + (t_hot - t_cold) / np.log(t_hot / t_cold), + ), ) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index b2e49395fe..cd4b745614 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -2,39 +2,57 @@ # # SPDX-License-Identifier: MIT """ -Approximate heat pump coefficient-of-performance (COP) profiles for different -heat sources and systems. Returns zero where source temperature higher than sink temperature. +Build heat pump coefficient-of-performance (COP) profiles for different heat +sources and heating system types. COP values below 1 are set to zero (infeasible +operating conditions). -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 `_). +For central heating (district heating), the approximation is based on +Jensen et al. (2018) using a thermodynamic model (see +:class:`CentralHeatingCopApproximator`). For decentral heating (individual +heating), the approximation uses quadratic regression from Staffell et al. +(2012) (see :class:`DecentralHeatingCopApproximator`). Relevant Settings ----------------- .. code:: yaml + sector: - heat_pump_sink_T_decentral_heating: + heat_pump_sink_T_individual_heating: + heat_sources: + urban central: + urban decentral: + rural: district_heating: forward_temperature: return_temperature: heat_source_cooling: heat_pump_cop_approximation: refrigerant: - heat_exchanger_pinch_point_temperature_difference + heat_exchanger_pinch_point_temperature_difference: isentropic_compressor_efficiency: heat_loss: min_delta_t_lift: - heat_pump_sources: - urban central: - urban decentral: - rural: + geothermal: + constant_temperature_celsius: + Inputs ------ -- `resources//temp_soil_total`: Ground temperature -- `resources//temp_air_total`: Air temperature +- ``resources//central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc``: + District heating forward (supply) temperature profiles. +- ``resources//central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc``: + District heating return temperature profiles. +- ``resources//temp_air_total_base_s_{clusters}.nc``: + Ambient air temperature (if air source heat pumps are configured). +- ``resources//temp_soil_total_base_s_{clusters}.nc``: + Ground/soil temperature (if ground source heat pumps are configured). +- ``resources//temp_ptes_top_profiles_base_s_{clusters}_{planning_horizons}.nc``: + PTES top temperature profiles (if PTES is configured as heat source). Outputs ------- -- `resources//cop_profiles.nc`: Heat pump coefficient-of-performance (COP) profiles +- ``resources//cop_profiles_base_s_{clusters}_{planning_horizons}.nc``: + Heat pump COP profiles with dimensions (time, name, heat_source, heat_system). """ import pandas as pd @@ -47,6 +65,7 @@ from scripts.build_cop_profiles.decentral_heating_cop_approximator import ( DecentralHeatingCopApproximator, ) +from scripts.definitions.heat_source import HeatSource from scripts.definitions.heat_system_type import HeatSystemType @@ -60,19 +79,29 @@ def get_cop( """ Calculate the coefficient of performance (COP) for a heating system. + Uses different approximation methods depending on the heating system type: + - Central heating: Thermodynamic model from Jensen et al. (2018) + - Decentral heating: Quadratic regression from Staffell et al. (2012) + Parameters ---------- heat_system_type : str - The type of heating system. + The type of heating system (e.g., "urban central", "urban decentral", "rural"). heat_source : str - The heat source used in the heating system. + The heat source used (e.g., "air", "ground", "ptes", "geothermal"). source_inlet_temperature_celsius : xr.DataArray The inlet temperature of the heat source in Celsius. + sink_outlet_temperature_celsius : xr.DataArray, optional + The outlet temperature of the heat sink (forward temperature) in Celsius. + Required for central heating systems. + sink_inlet_temperature_celsius : xr.DataArray, optional + The inlet temperature of the heat sink (return temperature) in Celsius. + Required for central heating systems. Returns ------- xr.DataArray - The calculated coefficient of performance (COP) for the heating system. + The calculated COP values. Values below 1 are set to zero. """ if HeatSystemType(heat_system_type).is_central: return CentralHeatingCopApproximator( @@ -106,6 +135,122 @@ def get_cop( ).cop +def get_source_temperature( + snakemake_params: dict, snakemake_input: dict, heat_source_name: str +) -> float | xr.DataArray: + """ + Retrieve the temperature of a heat source. + + Heat sources can have either constant temperatures (specified in config) + or time-varying temperatures (loaded from input files). + + Parameters + ---------- + snakemake_params : dict + Snakemake parameters containing constant temperature values. + snakemake_input : dict + Snakemake input files containing temperature profile paths. + heat_source_name : str + Name of the heat source (e.g., "air", "ground", "geothermal", "ptes"). + + Returns + ------- + float | xr.DataArray + Temperature in Celsius. Returns a float for constant-temperature sources + or an xr.DataArray for time-varying sources. + + Raises + ------ + ValueError + If a constant-temperature source lacks its parameter or a time-varying + source lacks its input file. + """ + heat_source = HeatSource(heat_source_name) + if heat_source.has_constant_temperature: + return snakemake_params[f"constant_temperature_{heat_source_name}"] + else: + return xr.open_dataarray(snakemake_input[f"temp_{heat_source_name}"]) + + +def get_source_inlet_temperature( + source_temperature: float | xr.DataArray, + central_heating_return_temperature: xr.DataArray, +) -> float | xr.DataArray: + """ + Determine the effective source inlet temperature for the heat pump. + + For heat sources with preheating capability (e.g., PTES), when the source + temperature exceeds the return temperature, a preheater can be used to + preheat the return flow. In this case, the heat pump sees the return + temperature as its effective source inlet (since it lifts from there). + Otherwise, the heat pump draws directly from the source temperature. + + Notes + ----- + We assume ideal heat exchangers with no temperature losses. + + Parameters + ---------- + heat_source_name : str + Name of the heat source. + source_temperature : float | xr.DataArray + Temperature of the heat source in Celsius. + central_heating_return_temperature : xr.DataArray + District heating return temperature in Celsius. + + Returns + ------- + float | xr.DataArray + Effective source inlet temperature for the heat pump in Celsius. + """ + # When source temperature > return temperature, preheater is used: + # heat pump lifts from return temperature (after preheating). + # When source temperature <= return temperature, no preheating: + # heat pump draws directly from the source. + return xr.where( + source_temperature > central_heating_return_temperature, + central_heating_return_temperature, + source_temperature, + ) + + +def get_sink_inlet_temperature( + source_temperature: float | xr.DataArray, + central_heating_return_temperature: xr.DataArray, +) -> float | xr.DataArray: + """ + Determine the effective sink inlet temperature for the heat pump. + + For heat sources with preheating capability (e.g., PTES), when the source + temperature exceeds the return temperature, a preheater raises the return + flow to forward temperature. The heat pump then lifts from return to forward + temperature. When preheating is not used (source <= return), the heat pump + receives water at return temperature and heats it to forward temperature. + + When source temperature > return temperature, preheater is used: preheater raises return flow, heat pump inlet is at source temperature. + When source temperature <= return temperature, no preheating: heat pump inlet is at return temperature. + + Parameters + ---------- + heat_source_name : str + Name of the heat source. + source_temperature : float | xr.DataArray + Temperature of the heat source in Celsius. + central_heating_return_temperature : xr.DataArray + District heating return temperature in Celsius. + + Returns + ------- + float | xr.DataArray + Effective sink inlet temperature for the heat pump in Celsius. + """ + return xr.where( + source_temperature > central_heating_return_temperature, + source_temperature, + central_heating_return_temperature, + ) + + if __name__ == "__main__": if "snakemake" not in globals(): from scripts._helpers import mock_snakemake @@ -125,36 +270,38 @@ def get_cop( ) cop_all_system_types = [] - for heat_system_type, heat_sources in snakemake.params.heat_pump_sources.items(): + for heat_system_type, heat_sources in snakemake.params.heat_sources.items(): cop_this_system_type = [] - for heat_source in heat_sources: - if ( - heat_source in snakemake.params.limited_heat_sources - and snakemake.params.limited_heat_sources[heat_source][ - "constant_temperature_celsius" - ] - is not False - ): - source_inlet_temperature_celsius = ( - snakemake.params.limited_heat_sources[heat_source][ - "constant_temperature_celsius" - ] - ) - else: - if f"temp_{heat_source}" not in snakemake.input.keys(): - raise ValueError( - f"Missing input temperature for heat source {heat_source}." - ) - source_inlet_temperature_celsius = xr.open_dataarray( - snakemake.input[f"temp_{heat_source}"] - ) + if not heat_sources: + cop_all_system_types.append( + central_heating_forward_temperature.expand_dims( + heat_source=pd.Index([], name="heat_source") + ).isel(heat_source=slice(0, 0)) + ) + continue + for heat_source_name in heat_sources: + source_temperature_celsius = get_source_temperature( + snakemake_params=snakemake.params, + snakemake_input=snakemake.input, + heat_source_name=heat_source_name, + ) + + source_inlet_temperature_celsius = get_source_inlet_temperature( + source_temperature=source_temperature_celsius, + central_heating_return_temperature=central_heating_return_temperature, + ) + + sink_inlet_temperature_celsius = get_sink_inlet_temperature( + source_temperature=source_temperature_celsius, + central_heating_return_temperature=central_heating_return_temperature, + ) cop_da = get_cop( heat_system_type=heat_system_type, - heat_source=heat_source, + heat_source=heat_source_name, source_inlet_temperature_celsius=source_inlet_temperature_celsius, sink_outlet_temperature_celsius=central_heating_forward_temperature, - sink_inlet_temperature_celsius=central_heating_return_temperature, + sink_inlet_temperature_celsius=sink_inlet_temperature_celsius, ) cop_this_system_type.append(cop_da) cop_all_system_types.append( @@ -165,7 +312,7 @@ def get_cop( cop_dataarray = xr.concat( cop_all_system_types, - dim=pd.Index(snakemake.params.heat_pump_sources.keys(), name="heat_system"), + dim=pd.Index(snakemake.params.heat_sources.keys(), name="heat_system"), ) cop_dataarray.to_netcdf(snakemake.output.cop_profiles) diff --git a/scripts/build_direct_heat_source_utilisation_profiles.py b/scripts/build_direct_heat_source_utilisation_profiles.py deleted file mode 100644 index 674672292c..0000000000 --- a/scripts/build_direct_heat_source_utilisation_profiles.py +++ /dev/null @@ -1,107 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# 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`). - -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 -""" - -import logging - -import xarray as xr - -from scripts._helpers import configure_logging, set_scenario_config - -logger = logging.getLogger(__name__) - - -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.limited_heat_sources.keys(): - return snakemake.params.limited_heat_sources[heat_source_key][ - "constant_temperature_celsius" - ] - else: - raise ValueError( - f"Unknown heat source {heat_source_key}. Must be one of " - f"{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 scripts._helpers import mock_snakemake - - snakemake = mock_snakemake( - "build_cop_profiles", - clusters=48, - ) - configure_logging(snakemake) - 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_existing_heating_distribution.py b/scripts/build_existing_heating_distribution.py index f1e26aa173..4e60a0b565 100644 --- a/scripts/build_existing_heating_distribution.py +++ b/scripts/build_existing_heating_distribution.py @@ -145,9 +145,7 @@ def build_existing_heating(): # add large-scale heat pump sources as columns for district heating with 0 capacity - for heat_pump_source in snakemake.params.sector["heat_pump_sources"][ - "urban central" - ]: + for heat_pump_source in snakemake.params.sector["heat_sources"]["urban central"]: nodal_heat_name_tech[("urban central", f"{heat_pump_source} heat pump")] = 0.0 nodal_heat_name_tech.to_csv(snakemake.output.existing_heating_distribution) diff --git a/scripts/build_geothermal_heat_potential.py b/scripts/build_geothermal_heat_potential.py index df573866b7..1dbafa1d66 100644 --- a/scripts/build_geothermal_heat_potential.py +++ b/scripts/build_geothermal_heat_potential.py @@ -2,44 +2,76 @@ # # SPDX-License-Identifier: MIT """ -Build heat source potentials for a given heat source. +Build geothermal heat source potentials for district heating networks. -This script maps and aggregates geothermal heat source potentials `onshore_regions`. Input data is provided on LAU-level and is aggregated to the onshore regions. -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. +This script maps and aggregates geothermal heat source potentials from LAU-level +data to onshore regions. It converts supply potentials from Manz et al. (2024) +to technical potentials (MW) and scales them based on the actual temperature +differences in the district heating system. +Temperature-Based Scaling +------------------------- +Manz et al. (2024) assume a design temperature difference of 15 K when computing +geothermal heat potentials. This script scales the potentials based on the actual +temperature delta achievable in the district heating system: + +a) If source_temperature > forward_temperature (direct utilisation), potentials simply scale linearly in both directions, depending on the actual temperature difference (source_temperature - return_temperature): + scale_factor = (source_temperature - return_temperature) / 15 K + +b) If forward_temperature >= source_temperature > return_temperature (preheating), the actual temperature difference is increased by the additional cooling through the geothermal-sourced heat pump `heat_source_cooling`: + scale_factor = (source_temperature - return_temperature + heat_source_cooling) / 15 K + +c) If source_temperature <= return_temperature (heat pump only), the actual temperature difference is only the exploitation induced by the heat pump: + scale_factor = heat_source_cooling / 15 K + +This results in time-varying heat source power profiles that reflect the actual +extractable heat capacity at each timestep. Relevant Settings ----------------- .. code:: yaml + sector: district_heating: + heat_source_cooling: 6 # K + geothermal: + constant_temperature_celsius: 65 limited_heat_sources: geothermal: - constant_temperature_celsius + ignore_missing_regions: false Inputs ------ -- `resources//regions_onshore.geojson` -- `resources//lau_regions.geojson` -- `resources//isi_heat_potentials.xlsx` +- ``data/isi_heat_utilisation_potentials.xlsx``: Heat potentials from Manz et al. (2024) +- ``resources//regions_onshore_base_s_{clusters}.geojson``: Onshore regions +- ``resources//central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc``: Forward temperature profiles +- ``resources//central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc``: Return temperature profiles +- ``data/lau_regions.zip``: LAU region geometries Outputs ------- -- `resources//heat_source_technical_potential_{heat_source}_base_s_{clusters}.csv` +- ``resources//heat_source_power_geothermal_base_s_{clusters}_{planning_horizons}.csv``: + Time-varying geothermal heat source power (MW) with time as index and regions as columns. Raises ------ -- ValueError if some LAU regions in ISI heat potentials are missing from the LAU Regions data. +ValueError + If LAU regions in ISI heat potentials are missing from the LAU Regions data. +ValueError + If onshore regions outside EU-27 have no heat source power and ignore_missing_regions is False. 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 +------ +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 """ import logging import geopandas as gpd import pandas as pd +import xarray as xr from scripts._helpers import configure_logging, set_scenario_config @@ -58,6 +90,99 @@ "Hydrothermal " # trailing space for hydrothermal necessary to get correct column ) +DESIGN_TEMPERATURE_DIFFERENCE = ( + 15 # K - assumed temperature difference for geothermal sources in Manz et al. 2024 +) + + +def scale_heat_source_power( + heat_source_power: pd.Series, + forward_temperature: xr.DataArray, + return_temperature: xr.DataArray, + source_temperature: float, + heat_source_cooling: float, +) -> xr.DataArray: + """ + Scale heat source power based on temperature differences. + + Manz et al. 2024 assume a temperature difference of 15K for geothermal heat + sources. This function scales the heat source power based on the actual + temperature difference in the district heating system. + + The scaling logic follows three cases: + a) If source_temperature > forward_temperature: + scale_factor = (source_temperature - return_temperature) / 15K + b) Elif source_temperature > return_temperature: + scale_factor = (source_temperature - return_temperature + heat_source_cooling) / 15K + c) Else: + scale_factor = heat_source_cooling / 15K + + Parameters + ---------- + heat_source_power : pd.Series + Base heat source power per region [MW]. Index: region names. + forward_temperature : xr.DataArray + Forward temperature profiles [°C]. Dims: (time, name). + return_temperature : xr.DataArray + Return temperature profiles [°C]. Dims: (name,). + source_temperature : float + Constant geothermal source temperature [°C]. + heat_source_cooling : float + Temperature drop in heat source when extracting heat via heat pump [K]. + + Returns + ------- + xr.DataArray + Scaled heat source power [MW]. Dims: (time, name). + """ + # Ensure alignment of regions + regions = heat_source_power.index + forward_temp = forward_temperature.sel(name=regions) + return_temp = return_temperature.sel(name=regions) + + # Broadcast return_temperature to match forward_temperature dimensions + return_temp_broadcast = return_temp.broadcast_like(forward_temp) + + # Compute scale factors for each case + # Case a: source_temp > forward_temp (direct utilisation possible) + scale_a = ( + source_temperature - return_temp_broadcast + ) / DESIGN_TEMPERATURE_DIFFERENCE + + # Case b: forward_temp >= source_temp > return_temp (preheating mode) + scale_b = ( + source_temperature - return_temp_broadcast + heat_source_cooling + ) / DESIGN_TEMPERATURE_DIFFERENCE + + # Case c: source_temp <= return_temp (HP-only mode) + scale_c = heat_source_cooling / DESIGN_TEMPERATURE_DIFFERENCE + + # Apply conditional logic + scale_factor = xr.where( + source_temperature > forward_temp, + scale_a, + xr.where( + source_temperature > return_temp_broadcast, + scale_b, + scale_c, + ), + ) + + # Convert heat_source_power to DataArray and broadcast + heat_source_power_da = xr.DataArray( + heat_source_power.squeeze().values, + dims=["name"], + coords={"name": regions}, + ) + + # Scale the heat source power + scaled_power = heat_source_power_da * scale_factor + + # Transpose to (time, name) for consistency with other profiles + scaled_power = scaled_power.transpose("time", "name") + + return scaled_power + def get_unit_conversion_factor( input_unit: str, @@ -210,6 +335,7 @@ def get_heat_source_power( snakemake = mock_snakemake( "build_geothermal_heat_potential", clusters=48, + planning_horizons=2040, ) configure_logging(snakemake) @@ -267,4 +393,22 @@ def get_heat_source_power( ignore_missing_regions=snakemake.params.ignore_missing_regions, ) - heat_source_power.to_csv(snakemake.output[0]) + # Load temperature profiles for scaling + forward_temperature = xr.open_dataarray( + snakemake.input.central_heating_forward_temperature_profiles + ) + return_temperature = xr.open_dataarray( + snakemake.input.central_heating_return_temperature_profiles + ) + + # Scale heat source power based on temperature differences + scaled_heat_source_power = scale_heat_source_power( + heat_source_power=heat_source_power, + forward_temperature=forward_temperature, + return_temperature=return_temperature, + source_temperature=snakemake.params.constant_temperature_celsius, + heat_source_cooling=snakemake.params.heat_source_cooling, + ) + + # Convert to DataFrame (time x regions) and save as CSV + scaled_heat_source_power.to_pandas().to_csv(snakemake.output.heat_source_power) diff --git a/scripts/build_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py new file mode 100644 index 0000000000..c37ab62692 --- /dev/null +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT +""" +Build heat source utilisation profiles for district heating networks. + +This script calculates when and how much heat from various sources (geothermal, +PTES, river water, etc.) can be used, based on the temperature relationship +between the heat source and the district heating network. + +Two utilisation modes are calculated: + +1. **Direct utilisation**: When the source temperature meets or exceeds the + forward temperature (T_source ≥ T_forward), the heat source can directly + supply the district heating network. Profile value is 1.0 (full utilisation) + or 0.0 (not possible). + +2. **Preheater utilisation**: When the source temperature is between the return + and forward temperatures (T_return < T_source < T_forward), the heat source + can preheat the return flow before a heat pump lifts it to forward temperature. + The profile value represents that share of the heat above the return temperature which is utilised to increase the heat pump's sink inflow temperature. The return flow serves as the source inlet. + +These profiles are used by ``prepare_sector_network.py`` to configure heat +utilisation links that model cascading temperature use: direct supply when +possible, preheating when beneficial, with heat pumps handling the final lift. + +Relevant Settings +----------------- +.. code:: yaml + + sector: + heat_sources: + urban central: + - air + - geothermal + district_heating: + heat_source_cooling: 6 # K + geothermal: + constant_temperature_celsius: 65 + +Inputs +------ +- ``resources//central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Forward temperature profiles for district heating networks (°C). +- ``resources//central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Return temperature profiles for district heating networks (°C). +- Heat source temperature profiles (for variable-temperature sources like PTES, air, ground). + +Outputs +------- +- ``resources//heat_source_direct_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Direct utilisation profiles indexed by (time, name, heat_source). + Values: 1.0 when T_source ≥ T_forward, 0.0 otherwise. +- ``resources//heat_source_preheater_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Preheater utilisation profiles indexed by (time, name, heat_source). + Values: heat extraction efficiency when T_return < T_source < T_forward, 0.0 otherwise. +""" + +import logging + +import xarray as xr + +from scripts._helpers import configure_logging, set_scenario_config +from scripts.definitions.heat_source import HeatSource + +logger = logging.getLogger(__name__) + + +def get_source_temperature( + snakemake_params: dict, snakemake_input: dict, heat_source_name: str +) -> float | xr.DataArray: + """ + Get the temperature profile or constant value for a heat source. + + Parameters + ---------- + snakemake_params : dict + Snakemake parameters containing constant temperatures for applicable sources. + snakemake_input : dict + Snakemake input files containing temperature profiles for variable sources. + heat_source_name : str + Name of the heat source (e.g., 'geothermal', 'ptes', 'air'). + + Returns + ------- + float | xr.DataArray + Either a constant temperature (float) for sources like geothermal, + or a DataArray with time-varying temperatures for sources like PTES or air. + + Raises + ------ + ValueError + If the required temperature data is not available in params or inputs. + """ + heat_source = HeatSource(heat_source_name) + if heat_source.has_constant_temperature: + try: + return snakemake_params[f"constant_temperature_{heat_source_name}"] + except KeyError: + raise ValueError( + f"Constant temperature for heat source {heat_source_name} not specified in parameters." + ) + + else: + if f"temp_{heat_source_name}" not in snakemake_input.keys(): + raise ValueError( + f"Missing input temperature for heat source {heat_source_name}." + ) + return xr.open_dataarray(snakemake_input[f"temp_{heat_source_name}"]) + + +def get_direct_utilisation_profile( + source_temperature: float | xr.DataArray, forward_temperature: xr.DataArray +) -> xr.DataArray | float: + """ + Calculate when a heat source can directly supply district heating. + + Direct utilisation is possible when the source temperature meets or exceeds + the required forward temperature of the district heating network. + + Parameters + ---------- + source_temperature : float | xr.DataArray + Heat source temperature in °C. If float, applies uniformly. + If DataArray, indexed by (time, name). + forward_temperature : xr.DataArray + District heating forward temperature profiles in °C, + indexed by (time, name). + + Returns + ------- + xr.DataArray + Binary profile: 1.0 where T_source ≥ T_forward (direct use possible), + 0.0 otherwise. + """ + return xr.where(source_temperature >= forward_temperature, 1.0, 0.0) + + +def get_preheater_utilisation_profile( + source_temperature: float | xr.DataArray, + forward_temperature: xr.DataArray, + return_temperature: xr.DataArray, + heat_source_cooling: float, +) -> xr.DataArray | float: + """ + Calculate preheater utilisation efficiency for intermediate-temperature sources. + + When a heat source temperature is between the return and forward temperatures, + it can preheat the return flow before a heat pump provides the final temperature + lift. This improves overall efficiency by reducing the heat pump's lift. + + The efficiency represents the fraction of heat extracted from the source that + goes into preheating (vs. the additional cooling through the heat pump): + + efficiency = (T_source - T_return) / (T_source - T_return + heat_source_cooling) + + Parameters + ---------- + source_temperature : float | xr.DataArray + Heat source temperature in °C. If float, applies uniformly. + If DataArray, indexed by (time, name). + forward_temperature : xr.DataArray + District heating forward temperature profiles in °C, + indexed by (time, name). + return_temperature : xr.DataArray + District heating return temperature profiles in °C, + indexed by (time, name). + heat_source_cooling : float | xr.DataArray + Additional temperature drop (K) when extracting heat from the source + through the heat pump, beyond the preheating contribution. + + Returns + ------- + xr.DataArray + Preheater efficiency profile: value in (0, 1) where T_return < T_source < T_forward, + 0.0 otherwise (source too cold or hot enough for direct use). + """ + return xr.where( + (source_temperature < forward_temperature) + * (source_temperature > return_temperature), + (source_temperature - return_temperature) + / (source_temperature - return_temperature + heat_source_cooling), + 0.0, + ) + + +def get_heat_pump_cooling( + heat_source_name: str, + default_heat_source_cooling: float, + snakemake_input: dict, + return_temperature: xr.DataArray = None, +) -> float | xr.DataArray: + """ + Get the additional heat source cooling (temperature drop) through the heat pump for a heat source. + + For PTES, this equals the temperature difference between + return flow and bottom layers (return_temperature - bottom_temperature), which can + be time-varying. For other sources, uses the default constant value. + + Parameters + ---------- + heat_source_name : str + Name of the heat source (e.g., 'ptes', 'geothermal', 'air'). + default_heat_source_cooling : float + Default heat source cooling in Kelvin, from config. + snakemake_input : dict + Snakemake input files, may contain PTES temperature profiles. + return_temperature : xr.DataArray, optional + District heating return temperature profiles in °C. Required for PTES. + + Returns + ------- + float | xr.DataArray + Heat source cooling in Kelvin. Returns a float for most sources, + or a DataArray for PTES when temperatures vary with time. + + Raises + ------ + ValueError + If heat source is PTES but bottom temperature profile is not provided. + """ + if heat_source_name == "ptes": + if "temp_ptes_bottom" not in snakemake_input.keys(): + raise ValueError( + "PTES heat source requires bottom temperature profile " + "(temp_ptes_bottom) to calculate heat source cooling." + ) + if return_temperature is None: + raise ValueError( + "PTES heat source requires return_temperature to calculate heat pump cooling." + ) + ptes_bottom_temperature = xr.open_dataarray(snakemake_input["temp_ptes_bottom"]) + return return_temperature - ptes_bottom_temperature + return default_heat_source_cooling + + +if __name__ == "__main__": + if "snakemake" not in globals(): + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_cop_profiles", + clusters=48, + ) + configure_logging(snakemake) + set_scenario_config(snakemake) + + heat_sources: list[str] = snakemake.params.heat_sources + ptes_enable: bool = snakemake.params.ptes_enable + + # Validate PTES configuration + if ptes_enable and "ptes" not in heat_sources: + raise ValueError( + "PTES is enabled (district_heating.ptes.enable=true) but 'ptes' " + "is not in heat_sources.urban_central. PTES requires being listed in heat_sources to create the necessary buses and links for heat discharge to the 'urban central heat' bus." + ) + + central_heating_forward_temperature: xr.DataArray = xr.open_dataarray( + snakemake.input.central_heating_forward_temperature_profiles + ) + central_heating_return_temperature: xr.DataArray = xr.open_dataarray( + snakemake.input.central_heating_return_temperature_profiles + ) + + xr.concat( + [ + get_direct_utilisation_profile( + source_temperature=get_source_temperature( + snakemake_params=snakemake.params, + snakemake_input=snakemake.input, + heat_source_name=heat_source_key, + ), + forward_temperature=central_heating_forward_temperature, + ).assign_coords(heat_source=heat_source_key) + for heat_source_key in heat_sources + ], + dim="heat_source", + ).to_netcdf(snakemake.output.heat_source_direct_utilisation_profiles) + + xr.concat( + [ + get_preheater_utilisation_profile( + source_temperature=get_source_temperature( + heat_source_name=heat_source_key, + snakemake_params=snakemake.params, + snakemake_input=snakemake.input, + ), + forward_temperature=central_heating_forward_temperature, + return_temperature=central_heating_return_temperature, + heat_source_cooling=get_heat_pump_cooling( + heat_source_name=heat_source_key, + default_heat_source_cooling=snakemake.params.heat_source_cooling, + snakemake_input=snakemake.input, + return_temperature=central_heating_return_temperature, + ), + ).assign_coords(heat_source=heat_source_key) + for heat_source_key in heat_sources + ], + dim="heat_source", + ).to_netcdf(snakemake.output.heat_source_preheater_utilisation_profiles) diff --git a/scripts/build_ptes_operations/ptes_temperature_approximator.py b/scripts/build_ptes_operations/ptes_temperature_approximator.py index 51197eff1b..61140ef31d 100644 --- a/scripts/build_ptes_operations/ptes_temperature_approximator.py +++ b/scripts/build_ptes_operations/ptes_temperature_approximator.py @@ -2,14 +2,18 @@ # # SPDX-License-Identifier: MIT +import logging + import xarray as xr +logger = logging.getLogger(__name__) + class PtesTemperatureApproximator: """ A unified class to handle pit thermal energy storage (PTES) temperature-related calculations. - It calculates top temperature profiles, determines when supplemental heating is needed, + It calculates top temperature profiles, determines when charge or discharge boosting is needed, and approximates storage capacity based on temperature differences. Attributes @@ -18,18 +22,33 @@ class PtesTemperatureApproximator: The forward temperature profile from the district heating network. return_temperature : xr.DataArray The return temperature profile from the district heating network. - max_ptes_top_temperature : float - Maximum operational temperature of top layer in PTES, default 90°C. - min_ptes_bottom_temperature : float - Minimum operational temperature of bottom layer in PTES, default 35°C. + top_temperature : float | str + Operational temperature specification for top layer in PTES. + bottom_temperature : float | str + Operational temperature specification for bottom layer in PTES. + charge_boosting_required : bool + Whether charge boosting is required/allowed. + discharge_boosting_required : bool + Whether discharge boosting is required/allowed. + temperature_dependent_capacity : bool + Whether storage capacity varies with temperature. If False, assumes constant capacity. + design_top_temperature : float + Maximum design temperature for the top layer of PTES, used for capacity normalization. + design_bottom_temperature : float + Minimum design temperature for the bottom layer of PTES, used for capacity normalization. """ def __init__( self, forward_temperature: xr.DataArray, return_temperature: xr.DataArray, - max_ptes_top_temperature: float = 90, - min_ptes_bottom_temperature: float = 35, + top_temperature: float | str, + bottom_temperature: float | str, + charge_boosting_required: bool, + discharge_boosting_required: bool, + temperature_dependent_capacity: bool, + design_top_temperature: float, + design_bottom_temperature: float, ): """ Initialize PtesTemperatureApproximator. @@ -40,69 +59,257 @@ def __init__( The forward temperature profile from the district heating network. return_temperature : xr.DataArray The return temperature profile from the district heating network. - max_ptes_top_temperature : float, optional - Maximum operational temperature of top layer in PTES, default 90°C. - min_ptes_bottom_temperature : float, optional - Minimum operational temperature of bottom layer in PTES, default 35°C. + top_temperature : float | str + Operational temperature of top layer in PTES. Either a float value or 'forward' for dynamic profiles. + bottom_temperature : float | str + Operational temperature of bottom layer in PTES. Either a float value or 'return' for dynamic profiles. + charge_boosting_required : bool + Whether charge boosting is required/allowed. + discharge_boosting_required : bool + Whether discharge boosting is required/allowed. + temperature_dependent_capacity : bool + Whether storage capacity varies with temperature. If False, assumes constant capacity. + design_top_temperature : float + Maximum design temperature for the top layer of PTES, used for capacity normalization + and clipping dynamic top temperature profiles. + design_bottom_temperature : float + Minimum design temperature for the bottom layer of PTES, used for capacity normalization. """ self.forward_temperature = forward_temperature self.return_temperature = return_temperature - self.max_ptes_top_temperature = max_ptes_top_temperature - self.min_ptes_bottom_temperature = min_ptes_bottom_temperature + self.top_temperature = top_temperature + self.bottom_temperature = bottom_temperature + self.charge_boosting_required = charge_boosting_required + self.discharge_boosting_required = discharge_boosting_required + self.temperature_dependent_capacity = temperature_dependent_capacity + self.design_top_temperature = design_top_temperature + self.design_bottom_temperature = design_bottom_temperature + + if self.charge_boosting_required: + raise NotImplementedError( + "Charge boosting for PTES is currently not supported but might be retintroduced in the future." + ) @property - def top_temperature(self) -> xr.DataArray: + def top_temperature_profile(self) -> xr.DataArray: """ - Forward temperature clipped at the maximum PTES temperature. + PTES top layer temperature profile. + + Returns either the forward temperature (if top_temperature == 'forward'), + clipped to the design_top_temperature, or a constant temperature profile + (if top_temperature is a numeric value). Returns ------- xr.DataArray The resulting top temperature profile for PTES. """ - return self.forward_temperature.where( - self.forward_temperature <= self.max_ptes_top_temperature, - self.max_ptes_top_temperature, - ) + if self.top_temperature == "forward": + logger.info( + f"PTES top temperature profile: Using dynamic forward temperature from district heating network, clipped by design top temperature to {self.design_top_temperature}°C " + f"Forward temperature range: {float(self.forward_temperature.min().values):.1f}°C to {float(self.forward_temperature.max().values):.1f}°C)" + ) + return self.forward_temperature.where( + self.forward_temperature <= self.design_top_temperature, + self.design_top_temperature, + ) + elif isinstance(self.top_temperature, (int, float)): + logger.info( + f"PTES top temperature profile: Using constant temperature of {self.top_temperature}°C " + f"for all {self.forward_temperature.size} snapshots and nodes" + ) + return xr.full_like(self.forward_temperature, self.top_temperature) + else: + raise ValueError( + f"Invalid top_temperature: {self.top_temperature}. " + "Must be 'forward' or a numeric value." + ) @property - def bottom_temperature(self) -> xr.DataArray: + def bottom_temperature_profile(self) -> xr.DataArray: """ - Return temperature clipped at the minimum PTES temperature. + PTES bottom layer temperature profile. + + Returns either the return temperature (if bottom_temperature == 'return') + or a constant temperature profile (if bottom_temperature is a numeric value). Returns ------- xr.DataArray The resulting bottom temperature profile for PTES. """ - return self.min_ptes_bottom_temperature + if self.bottom_temperature == "return": + logger.info( + f"PTES bottom temperature profile: Using dynamic return temperature from district heating network " + f"(shape: {self.return_temperature.shape}, range: {float(self.return_temperature.min().values):.1f}°C to {float(self.return_temperature.max().values):.1f}°C)" + ) + return self.return_temperature + elif isinstance(self.bottom_temperature, (int, float)): + logger.info( + f"PTES bottom temperature profile: Using constant temperature of {self.bottom_temperature}°C " + f"for all {self.return_temperature.size} snapshots and nodes" + ) + return xr.full_like(self.return_temperature, self.bottom_temperature) + else: + raise ValueError( + f"Invalid bottom_temperature: {self.bottom_temperature}. " + "Must be 'return' or a numeric value." + ) @property - def direct_utilisation_profile(self) -> xr.DataArray: + def e_max_pu(self) -> xr.DataArray: """ - Identify timesteps requiring supplemental heating. + Calculate e_max_pu for PTES as design_temperature_delta / actual_temperature_delta. Returns ------- xr.DataArray - Array with 1 for direct PTES usage, 0 if supplemental heating is needed. + Normalized delta T values between 0 and 1, representing the + available storage capacity as a fraction of maximum design capacity. + If temperature_dependent_capacity is False, returns constant capacity of 1.0. """ - return (self.forward_temperature <= self.max_ptes_top_temperature).astype(int) + if self.temperature_dependent_capacity: + delta_t = self.top_temperature_profile - self.bottom_temperature_profile + # Get max possible delta_t for normalization + max_delta_t = self.design_top_temperature - self.design_bottom_temperature + normalized_delta_t = delta_t / max_delta_t + result = normalized_delta_t.clip(min=0) # Ensure non-negative values + logger.info( + f"PTES capacity (e_max_pu): Calculating temperature-dependent capacity. " + f"Normalization: max_delta_t={max_delta_t:.2f}K (max_top={self.design_top_temperature:.2f}°C, min_bottom={self.design_bottom_temperature:.2f}°C). " + f"Resulting capacity range: {float(result.min().values):.3f} to {float(result.max().values):.3f} p.u." + ) + return result + else: + logger.info( + f"PTES capacity (e_max_pu): Using constant capacity of 1.0 p.u. (temperature-independent) " + f"for all {self.forward_temperature.size} snapshots and nodes" + ) + return xr.ones_like(self.forward_temperature) @property - def e_max_pu(self) -> xr.DataArray: + def boost_per_discharge(self) -> xr.DataArray: """ - Calculate the normalized delta T for TES capacity in relation to - max and min temperature. + Calculate the additional lift required between the store's + current top temperature and the forward temperature with the lift + already achieved inside the store. + + Notes + ----- + The total thermal output required to reach the forward temperature is: + + Q_total = Q_discharge + Q_boost + + The total heat transfer is partitioned into: + + Q_discharge = Ṽ·ρ·cₚ·(T_top − T_bottom) + Q_boost = Ṽ·ρ·cₚ·(T_forward − T_top) + + Solving this to constant Ṽ gives α as the ratio of required boost to available store energy: + + α = Q_boost / Q_discharge + = (T_forward − T_top) / (T_top − T_bottom) + + This expression quantifies the share of PTES output that is covered + by stored energy relative to the additional heating needed to meet + the desired forward temperature. Returns ------- xr.DataArray - Normalized delta T values between 0 and 1, representing the - available storage capacity as a percentage of maximum capacity. + The resulting fraction of PTES charge that must be further heated. + """ + if self.discharge_boosting_required: + result = ( + (self.forward_temperature - self.top_temperature_profile) + / (self.top_temperature_profile - self.bottom_temperature_profile) + ).where(self.forward_temperature > self.top_temperature_profile, 0) + + # Count how many snapshots require boosting + boosting_needed = ( + (self.forward_temperature > self.top_temperature_profile).sum().values + ) + total_snapshots = self.forward_temperature.size + + logger.info( + f"Discharge boosting (boost_per_discharge): Enabled. " + f"Boosting required for {int(boosting_needed)}/{total_snapshots} snapshot-node combinations " + f"(ratio range: {float(result.min().values):.3f} to {float(result.max().values):.3f})" + ) + return result + else: + logger.info( + f"Discharge boosting (boost_per_discharge): Not required. " + f"Returning boost_per_discharge=0 for all {self.forward_temperature.size} snapshots and nodes" + ) + return xr.zeros_like(self.forward_temperature) + + @property + def boost_per_charge(self) -> xr.DataArray: + """ + Calculate the additional boost energy ratio required during charging. + + .. note:: + Charge boosting is currently not implemented and will raise + NotImplementedError if charge_boosting_required is True. + + This calculates how much additional energy is needed to raise the PTES + top layer from the forward temperature to the design top temperature, + relative to the energy delivered by charging. + + Notes + ----- + To fill the storage from the return temperature all the way up to its + design top temperature, the total thermal energy required is split into: + + Q_charge = Ṽ·ρ·cₚ·(T_forward − T_bottom) + Q_boost = Ṽ·ρ·cₚ·(T_top − T_forward) + + - Q_charge is the energy delivered by charging to the forward setpoint. + - Q_boost is the extra boost energy needed to reach the design top temperature. + + Defining α as the ratio of boost energy to charge energy: + + α = Q_boost / Q_charge + = (T_top − T_forward) / (T_forward − T_return) + + Wherever the forward temperature meets or exceeds the design top temperature, + α is set to zero since no further boost is needed. + + Returns + ------- + xr.DataArray + The ratio of additional boost energy needed to the energy delivered by charging. """ - delta_t = self.top_temperature - self.return_temperature - normalized_delta_t = delta_t / ( - self.max_ptes_top_temperature - self.bottom_temperature - ) - return normalized_delta_t.clip(min=0) # Ensure non-negative values + if self.charge_boosting_required: + # Get the max top temperature value + max_top = ( + self.top_temperature + if isinstance(self.top_temperature, (int, float)) + else self.top_temperature_profile + ) + result = ( + ( + (max_top - self.forward_temperature) + / (self.forward_temperature - self.return_temperature) + ) + .where(self.forward_temperature < max_top, 0) + .clip(max=1) + ) + + # Count how many snapshots require boosting + boosting_needed = (self.forward_temperature < max_top).sum().values + total_snapshots = self.forward_temperature.size + + logger.info( + f"Charge boosting (boost_per_charge): Enabled. " + f"Boosting required for {int(boosting_needed)}/{total_snapshots} snapshot-node combinations " + f"(ratio range: {float(result.min().values):.3f} to {float(result.max().values):.3f})" + ) + return result + else: + logger.info( + f"Charge boosting (boost_per_charge): Not required. " + f"Returning boost_per_charge=0 for all {self.forward_temperature.size} snapshots and nodes" + ) + return xr.zeros_like(self.forward_temperature) diff --git a/scripts/build_ptes_operations/run.py b/scripts/build_ptes_operations/run.py index 169e7612ef..347c5e5efe 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -2,59 +2,73 @@ # # SPDX-License-Identifier: MIT """ -Approximate the top temperature of the pit thermal energy storage (PTES), ensuring that the temperature does not -exceed the operational limit. +Build PTES (Pit Thermal Energy Storage) operational profiles. -Determine whether supplemental heating is needed. A binary indicator is generated: - - 1: The forward temperature is less than or equal to the TES maximum; direct usage is possible. - - 0: The forward temperature exceeds the TES maximum; supplemental heating (e.g., via a heat pump) is required. +This script calculates temperature and capacity profiles for pit thermal energy +storage systems integrated with district heating networks. It determines: -Calculate dynamic PTES capacity profiles based on district heating forward and return flow temperatures. -The linear relation between temperature difference and capacity is taken from Sorknaes (2018). +1. **Top temperature profiles**: The operational top layer temperature, either + following the district heating forward temperature (clipped to design limits) + or a constant value. -The capacity of thermal energy storage systems varies with the temperature difference -between the forward and return flows in district heating networks assuming a direct -integration of the storage. This script calculates normalized capacity factors (e_max_pu) -for PTES systems based on these temperature differences. +2. **Capacity profiles (e_max_pu)**: Normalized storage capacity based on the + temperature difference between top and bottom layers, relative to the design + temperature difference. This captures how storage capacity varies when + operating temperatures deviate from design conditions. + +3. **Discharge boosting profiles**: When the PTES top temperature is below the + required forward temperature, additional heating (boosting) is needed during + discharge. This profile quantifies the ratio of boost energy to stored energy. + +The outputs are used by ``prepare_sector_network.py`` to configure PTES storage +components, charger/discharger links, and optional resistive boosting infrastructure. Relevant Settings ----------------- .. code:: yaml - sector + + sector: district_heating: ptes: - dynamic_ptes_capacity: - supplemental_heating: - enable: - max_top_temperature: + enable: true + temperature_dependent_capacity: false # if true, e_max_pu varies with temperature difference (static but scaled if top/bottom are constant) + charge_boosting_required: false # currently not supported + discharge_resistive_boosting: false # if true, adds resistive boosting link for discharge boosting and disables heat pump boosting + top_temperature: 90 # or "forward" for dynamic + bottom_temperature: 35 # or "return" for dynamic + design_top_temperature: 90 # used to compute design temperature difference for e_max_pu if temperature_dependent_capacity is true + design_bottom_temperature: 35 # used to compute design temperature difference for e_max_pu if temperature_dependent_capacity is true Inputs ------ -- `resources//forward_temperature.nc` - Forward temperature profiles for the district heating networks. -- `resources//central_heating_return_temperature_profiles.nc`: - Return temperature profiles for the district heating networks. +- ``resources//central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Forward temperature profiles for district heating networks (°C). +- ``resources//central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Return temperature profiles for district heating networks (°C). Outputs ------- -- `resources//ptes_top_temperature_profiles.nc` - Clipped PTES top temperature profile (in °C). -- `resources//ptes_supplemental_heating_required.nc` - Binary indicator for additional heating (1 = direct PTES use, 0 = supplemental heating required). -- `resources//ptes_e_max_pu_profiles.nc` - Normalized PTES capacity profiles. - -Source ------- -Sorknæs, P. 2018. "Simulation method for a pit seasonal thermal energy storage system with a heat pump in a district heating system", Energy, Volume 152, https://doi.org/10.1016/j.energy.2018.03.152. -Approximate thermal energy storage (TES) top temperature and identify need for supplemental heating. +- ``resources//temp_ptes_top_profiles_base_s_{clusters}_{planning_horizons}.nc`` + PTES top layer temperature profile (°C), clipped to design_top_temperature. +- ``resources//ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Normalized PTES capacity profiles (0-1 p.u.). +- ``resources//ptes_boost_per_discharge_profiles_base_s_{clusters}_{planning_horizons}.nc`` + Discharge boosting ratio profiles. Values represent the ratio of boost energy + to discharge energy needed when T_forward > T_top. Only used when resistive + boosting is enabled. + +References +---------- +Sorknæs, P. (2018). "Simulation method for a pit seasonal thermal energy storage +system with a heat pump in a district heating system." Energy, Volume 152. +https://doi.org/10.1016/j.energy.2018.03.152 """ import logging import xarray as xr -from _helpers import set_scenario_config +from scripts._helpers import set_scenario_config from scripts.build_ptes_operations.ptes_temperature_approximator import ( PtesTemperatureApproximator, ) @@ -63,7 +77,7 @@ if __name__ == "__main__": if "snakemake" not in globals(): - from _helpers import mock_snakemake + from scripts._helpers import mock_snakemake snakemake = mock_snakemake( "build_ptes_operations", @@ -73,11 +87,15 @@ set_scenario_config(snakemake) - # Load temperature profiles logger.info( - "Loading district heating temperature profiles and constructing PTES temperature approximator" + "Loading district heating temperature profiles and calculating PTES operational profiles" ) - # Initialize unified PTES temperature class + logger.info(f"PTES configuration: {snakemake.params}") + + # Discharge boosting profiles are calculated when resistive boosting is enabled. + # For heat pump-based boosting, add "ptes" to central heating heat sources instead and disable resistive boosting. + discharge_boosting_required: bool = snakemake.params.discharge_resistive_boosting + ptes_temperature_approximator = PtesTemperatureApproximator( forward_temperature=xr.open_dataarray( snakemake.input.central_heating_forward_temperature_profiles @@ -85,34 +103,27 @@ return_temperature=xr.open_dataarray( snakemake.input.central_heating_return_temperature_profiles ), - max_ptes_top_temperature=snakemake.params.max_ptes_top_temperature, - min_ptes_bottom_temperature=snakemake.params.min_ptes_bottom_temperature, + top_temperature=snakemake.params.top_temperature, + bottom_temperature=snakemake.params.bottom_temperature, + charge_boosting_required=snakemake.params.charge_boosting_required, + discharge_boosting_required=discharge_boosting_required, + temperature_dependent_capacity=snakemake.params.temperature_dependent_capacity, + design_bottom_temperature=snakemake.params.design_bottom_temperature, + design_top_temperature=snakemake.params.design_top_temperature, ) - # Get PTES clipped top temperature profiles - logger.info( - f"Saving TES top temperature profile to {snakemake.output.ptes_top_temperature_profiles}" - ) - ptes_temperature_approximator.top_temperature.to_netcdf( + ptes_temperature_approximator.top_temperature_profile.to_netcdf( snakemake.output.ptes_top_temperature_profiles ) - # if snakemake.params.enable_supplemental_heating: - # Get PTES supplemental heating profiles - logger.info( - f"Saving PTES direct utilisation profile to {snakemake.output.ptes_direct_utilisation_profiles}" - ) - ptes_temperature_approximator.direct_utilisation_profile.to_netcdf( - snakemake.output.ptes_direct_utilisation_profiles + ptes_temperature_approximator.bottom_temperature_profile.to_netcdf( + snakemake.output.ptes_bottom_temperature_profiles ) - # if snakemake.params.enable_dynamic_capacity: - logger.info("Calculating dynamic PTES capacity profiles") - - # Get PTES capacity profiles - logger.info( - f"Saving PTES capacity profiles to {snakemake.output.ptes_e_max_pu_profiles}" - ) ptes_temperature_approximator.e_max_pu.to_netcdf( snakemake.output.ptes_e_max_pu_profiles ) + + ptes_temperature_approximator.boost_per_discharge.to_netcdf( + snakemake.output.ptes_boost_per_discharge_profiles + ) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py new file mode 100644 index 0000000000..26f1a74399 --- /dev/null +++ b/scripts/definitions/heat_source.py @@ -0,0 +1,395 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +import logging +from enum import Enum + +logger = logging.getLogger(__name__) + + +class HeatSource(Enum): + """ + Enumeration representing different heat sources for heat pumps and direct utilisation. + + Heat sources are categorized by their characteristics: + + **Inexhaustible sources** (AIR, GROUND, SEA_WATER): + Always available, no resource bus needed, heat pump draws from ambient. + + **Limited sources requiring a bus** (GEOTHERMAL, RIVER_WATER, PTES): + Have spatial/temporal constraints, require resource tracking via buses. + May support direct utilisation or preheating depending on temperature. + + **Sources with preheater** (PTES): + When source temperature is between return and forward temperatures, + can preheat return flow before heat pump provides final lift. + + Attributes + ---------- + GEOTHERMAL : str + Geothermal heat source with constant temperature. + RIVER_WATER : str + River water heat source with time-varying temperature. + SEA_WATER : str + Sea water heat source (treated as inexhaustible). + AIR : str + Ambient air heat source (inexhaustible). + GROUND : str + Ground/soil heat source (inexhaustible). + PTES : str + Pit Thermal Energy Storage discharge as heat source. + + See Also + -------- + HeatSystem : Defines heat system types (urban central, urban decentral, rural). + build_heat_source_utilisation_profiles : Calculates utilisation profiles for heat sources. + build_cop_profiles : Calculates COP profiles for heat pumps using these sources. + """ + + GEOTHERMAL = "geothermal" + RIVER_WATER = "river_water" + SEA_WATER = "sea_water" + AIR = "air" + GROUND = "ground" + PTES = "ptes" + + def __init__(self, *args): + super().__init__(*args) + + def __str__(self) -> str: + """ + Returns the string representation of the heat source. + + Returns + ------- + str + The string representation of the heat source. + """ + return self.value + + @property + def has_constant_temperature(self) -> bool: + """ + Check if the heat source has a constant (time-invariant) temperature. + + Constant-temperature sources (e.g., geothermal) have their temperature + specified in config rather than loaded from time series files. + + Returns + ------- + bool + True for geothermal, False for all other sources. + """ + if self == HeatSource.GEOTHERMAL: + return True + else: + return False + + @property + def requires_bus(self) -> bool: + """ + Returns whether the heat source is limited (vs. inexhaustible). + + Limited heat sources require a resource bus and have spatial/temporal constraints. + Inexhaustible sources (air, ground) are always available. + + Returns + ------- + bool + True if the heat source is limited, False if inexhaustible. + """ + if self in [ + HeatSource.GEOTHERMAL, + HeatSource.RIVER_WATER, + HeatSource.PTES, + ]: + return True + else: + return False + + @property + def requires_generator(self) -> bool: + """ + Check if the heat source requires a generator component in the network. + + Generators represent the extraction capacity from geothermal wells or + river water intakes, with associated capital costs and lifetimes. + + Returns + ------- + bool + True for geothermal and river_water, False otherwise. + """ + if self in [HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER]: + return True + else: + return False + + def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: + """ + Check if a heat pump should be built for this heat source. + + Most heat sources require a heat pump to lift temperature to the + forward temperature. PTES is special: it can use either a heat pump + or resistive boosting for temperature lift during discharge. + + Parameters + ---------- + ptes_discharge_resistive_boosting : bool + Whether PTES uses resistive heaters instead of heat pumps. + + Returns + ------- + bool + False for PTES with resistive boosting, True otherwise. + """ + if self == HeatSource.PTES and ptes_discharge_resistive_boosting: + logging.info( + "PTES configured with resistive boosting during discharge; " + "heat pump not built for PTES." + ) + return False + else: + return True + + def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: + """ + Returns the capital cost for the heat source generator. + + Parameters + ---------- + costs : pd.DataFrame + DataFrame containing cost information for different technologies. + overdim_factor : float + Factor to overdimension the heat generator. + heat_system : HeatSystem + The heat system for which to get the capital cost. + + Returns + ------- + float + The capital cost for the heat source generator. + + Notes + ----- + - For direct utilisation heat sources (geothermal), gets cost from technology-data. + - For other limited sources (like river_water), returns 0.0. + - For inexhaustible sources, this method shouldn't be called. + """ + if self in [HeatSource.GEOTHERMAL]: + return ( + costs.at[ + heat_system.heat_source_costs_name(self), + "capital_cost", + ] + * overdim_factor + ) + else: + return 0.0 + + def get_lifetime(self, costs, heat_system) -> float: + """ + Returns the lifetime for the heat source generator. + + Parameters + ---------- + costs : pd.DataFrame + DataFrame containing cost information for different technologies. + heat_system : HeatSystem + The heat system for which to get the lifetime. + + Returns + ------- + float + The lifetime for the heat source generator in years. + + Notes + ----- + - For direct utilisation heat sources (geothermal), gets lifetime from technology-data. + - For other limited sources (like river_water), returns infinity. + - For inexhaustible sources, this method shouldn't be called. + """ + if self in [HeatSource.GEOTHERMAL]: + return costs.at[heat_system.heat_source_costs_name(self), "lifetime"] + else: + return float("inf") + + def get_heat_pump_input_bus(self, nodes, heat_system: str) -> str: + """ + Get the secondary input bus for the heat pump link. + + The heat pump link has bus0 (electricity input), bus1 (heat output), + and optionally bus2 (heat source input). This method determines bus2 + based on the heat source type: + + - Inexhaustible sources: No bus2 (empty string) + - Limited sources: bus2 is the heat pump input bus (at return temperature if pre-heated, else at source temperature) + + Parameters + ---------- + nodes : pd.Index or list + The nodes for which to generate the bus name. + heat_system : str + The heat system identifier (e.g., 'urban central'). + + Returns + ------- + str + The bus2 name for the heat pump, or empty string if not applicable. + """ + if self.requires_bus: + return nodes + f" {self.heat_pump_input_carrier(heat_system)}" + else: + return "" + + def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: + """ + Get the efficiency2 (heat source consumption) for the heat pump link. + + The heat pump link uses an inverted bus configuration where bus0 is the + heat output (not input) to attribute capital costs to the heat bus. This + means the link operates with negative flow (p_max_pu=0, p_min_pu < 0). + + - Inexhaustible sources (air, ground, sea_water): Returns 1.0 since no + resource tracking is needed. + - Limited sources (geothermal, river_water, ptes): Returns 1 - 1/COP to + track heat drawn from the source bus. + + For a standard heat pump energy balance: + + Q_output = Q_source + W_electricity + COP = Q_output / W_electricity + + The fraction of output heat from the source is: + + Q_source / Q_output = (COP - 1) / COP + + However, since bus0 is the output in our inverted configuration, PyPSA + interprets efficiency2 as: input_bus2 = p_bus0 * efficiency2 + + With negative p_bus0 (heat flowing out), we need efficiency2 to give the + correct heat drawn from the source. The formula simplifies to: + + efficiency2 = 1 - 1/COP + + This ensures that for each unit of heat output, the link draws the correct + amount from the heat source bus. + + Parameters + ---------- + cop_heat_pump : float or pd.Series + The coefficient of performance of the heat pump. + + Returns + ------- + float or pd.Series + 1 - 1/COP for limited sources (tracks heat drawn from source bus). + + Raises + ------ + NotImplementedError + If called for inexhaustible heat sources. + + See Also + -------- + prepare_sector_network.add_heat : Creates heat pump links with this efficiency. + """ + if self.requires_bus: + return 1 - (1 / cop_heat_pump.clip(lower=0.001)) + else: + return None + + def heat_carrier(self, heat_system) -> str: + """ + Get the carrier name for heat from this source. + + Parameters + ---------- + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). + + Returns + ------- + str + Carrier name in format '{heat_system} {source} heat', + e.g., 'urban central ptes heat'. + """ + return f"{heat_system} {self} heat" + + def preheater_input_carrier(self, heat_system) -> str: + """ + Get the carrier name for partially-cooled heat from this source. + + Used in cascading temperature utilisation: heat that has been used + for direct supply but still has usable thermal energy. + + Parameters + ---------- + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). + + Returns + ------- + str + Carrier name with '-pre-heater input' suffix in format '{heat_system} {source} pre-heater input'. + """ + return f"{self.heat_carrier(heat_system)} pre-heater input" + + def heat_pump_input_carrier(self, heat_system) -> str: + """ + Get the carrier name for fully-cooled heat from this source. + + Represents heat pump input, for + final temperature lift by heat pump. + + Parameters + ---------- + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). + + Returns + ------- + str + Carrier name with '-heat-pump input' suffix in format '{heat_system} {source} heat-pump input'. + """ + return f"{self.heat_carrier(heat_system)} heat-pump input" + + def preheater_input_bus(self, nodes, heat_system) -> str: + """ + Get bus name for partially-cooled heat at the given nodes. + + Parameters + ---------- + nodes : pd.Index or str + Node identifier(s). + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). + + Returns + ------- + str + Bus name combining nodes with medium-temperature carrier in format 'nodes + {heat_system} {source} pre-heater input'. + """ + return nodes + f" {self.preheater_input_carrier(heat_system)}" + + def resource_bus(self, nodes, heat_system) -> str: + """ + Get the primary resource bus for heat from this source, e.g. `DE0 0 urban central geothermal heat` or `DE0 0 urban central ptes heat`. + + This is where heat enters the system from generators or storage + discharge, at the source's native temperature. + + Parameters + ---------- + nodes : pd.Index or str + Node identifier(s). + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). + + Returns + ------- + str + Bus name combining nodes with heat carrier in format 'nodes + {heat_system} {source} heat'. + """ + return nodes + f" {self.heat_carrier(heat_system)}" diff --git a/scripts/definitions/heat_system.py b/scripts/definitions/heat_system.py index 6dc043f51a..4509bdd6dc 100644 --- a/scripts/definitions/heat_system.py +++ b/scripts/definitions/heat_system.py @@ -5,6 +5,7 @@ from enum import Enum from scripts.definitions.heat_sector import HeatSector +from scripts.definitions.heat_source import HeatSource from scripts.definitions.heat_system_type import HeatSystemType @@ -207,7 +208,7 @@ def heat_demand_weighting(self, urban_fraction=None, dist_fraction=None) -> floa else: raise RuntimeError(f"Invalid heat system: {self}") - def heat_pump_costs_name(self, heat_source: str) -> str: + def heat_pump_costs_name(self, heat_source: HeatSource | str) -> str: """ Generates the name for the heat pump costs based on the heat source and system. @@ -215,20 +216,30 @@ def heat_pump_costs_name(self, heat_source: str) -> str: Parameters ---------- - heat_source : str - The heat source. + heat_source : HeatSource | str + The heat source (can be HeatSource enum or string). Returns ------- str The name for the heat pump costs. """ - if heat_source in ["ptes", "geothermal", "sea_water", "river_water"]: + # Convert string to HeatSource enum if needed + if not isinstance(heat_source, HeatSource): + heat_source = HeatSource(heat_source) + + # Check if this is an excess-heat-sourced heat pump + if heat_source in [ + HeatSource.PTES, + HeatSource.GEOTHERMAL, + HeatSource.SEA_WATER, + HeatSource.RIVER_WATER, + ]: return f"{self.central_or_decentral} excess-heat-sourced heat pump" else: - return f"{self.central_or_decentral} {heat_source}-sourced heat pump" + return f"{self.central_or_decentral} {heat_source.value}-sourced heat pump" - def heat_source_costs_name(self, heat_source: str) -> str: + def heat_source_costs_name(self, heat_source: HeatSource | str) -> str: """ Generates the name for direct source utilisation costs based on the heat source and system. @@ -236,15 +247,19 @@ def heat_source_costs_name(self, heat_source: str) -> str: Parameters ---------- - heat_source : str - The heat source. + heat_source : HeatSource | str + The heat source (can be HeatSource enum or string). Returns ------- str The name for the technology-data costs. """ - return f"{self.central_or_decentral} {heat_source} heat source" + # Convert string to HeatSource enum if needed + if not isinstance(heat_source, HeatSource): + heat_source = HeatSource(heat_source) + + return f"{self.central_or_decentral} {heat_source.value} heat source" @property def resistive_heater_costs_name(self) -> str: diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 5801f7fcbe..ef50600597 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -8,13 +8,104 @@ See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#sector """ -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from scripts.definitions.heat_source import HeatSource +from scripts.definitions.heat_system_type import HeatSystemType from scripts.lib.validation.config._base import ConfigModel +class _PtesConfig(BaseModel): + """ + Configuration for `sector.district_heating.ptes` settings. + + Pit thermal energy storage [PTES] settings. PTES is used only in district heating. + See `prepare_sector_network `_ + and `build_ptes_operations `_. + """ + + enable: bool = Field( + True, + description="Enable PTES. The function `add_heat()` in `prepare_sector_network` then adds the stores " + "` urban central water pits` as well as the links ` urban central water pits charger` " + "and ` urban central water pits discharger`. Important note: PTES discharge must be boosted when its top temperature is below the network forward temperature. This requires adding PTES as a heat source in urban central heating.", + ) + temperature_dependent_capacity: bool = Field( + False, + description="If True, the energy capacity is scaled as " + "`e_nom_pu=(top_temperature - bottom_temperature) / (design_top_temperature - design_bottom_temperature)`. " + "See `build_ptes_operations`.", + ) + charge_boosting_required: bool = Field( + False, + description="Deprecated. Not implemented.", + ) + discharge_resistive_boosting: bool = Field( + False, + description="If True, enables boosting by resistive heaters instead of heat pumps. " + "`prepare_sector_network` then adds the links ` urban central water pits resistive booster` " + "and ` urban central water pits resistive heater stand-alone` and reroutes heat generation " + "from resistive heaters accordingly. The required boosting energy is computed in `build_ptes_operations` " + "as `ptes_boost_per_discharge_profiles_base_s_.nc`.", + ) + top_temperature: float | Literal["forward"] = Field( + 90, + description="PTES top layer temperature in °C. When `top_temperature` falls below the nodal forward " + "temperature, additional heating (boosting) is needed during discharge following a similar logic as " + "for other heat sources. If set to 'forward', the PTES top temperature follows the forward temperature " + "profile dynamically.", + ) + bottom_temperature: float | Literal["return"] = Field( + 35, + description="PTES bottom layer temperature in °C. Can be set to 'return' to follow the return " + "temperature profile dynamically.", + ) + design_top_temperature: float = Field( + 90, + gt=0, + description="Design top temperature in °C for capacity calculation.", + ) + design_bottom_temperature: float = Field( + 35, + gt=0, + description="Design bottom temperature in °C for capacity calculation.", + ) + + @field_validator("top_temperature") + @classmethod + def validate_top_temperature(cls, v): + if isinstance(v, (int, float)) and v <= 0: + raise ValueError("top_temperature must be > 0 when specified as a number") + return v + + @field_validator("bottom_temperature") + @classmethod + def validate_bottom_temperature(cls, v): + if isinstance(v, (int, float)) and v <= 0: + raise ValueError( + "bottom_temperature must be > 0 when specified as a number" + ) + return v + + @model_validator(mode="after") + def validate_temperature_order(self): + top = self.top_temperature + bottom = self.bottom_temperature + if isinstance(top, (int, float)) and isinstance(bottom, (int, float)): + if top < bottom: + raise ValueError( + f"top_temperature ({top}) must be >= bottom_temperature ({bottom})" + ) + if self.design_top_temperature < self.design_bottom_temperature: + raise ValueError( + f"design_top_temperature ({self.design_top_temperature}) must be >= " + f"design_bottom_temperature ({self.design_bottom_temperature})" + ) + return self + + class _DistrictHeatingConfig(ConfigModel): """Configuration for `sector.district_heating` settings.""" @@ -59,14 +150,9 @@ class _DistrictHeatingConfig(ConfigModel): }, description="Supply temperature approximation settings.", ) - ptes: dict[str, Any] = Field( - default_factory=lambda: { - "dynamic_capacity": False, - "supplemental_heating": {"enable": False, "booster_heat_pump": False}, - "max_top_temperature": 90, - "min_bottom_temperature": 35, - }, - description="Pit thermal energy storage settings.", + ptes: _PtesConfig = Field( + default_factory=_PtesConfig, + description="Pit thermal energy storage (PTES) settings.", ) ates: dict[str, Any] = Field( default_factory=lambda: { @@ -95,28 +181,21 @@ class _DistrictHeatingConfig(ConfigModel): }, description="Heat pump COP approximation settings.", ) - limited_heat_sources: dict[str, Any] = Field( - default_factory=lambda: { - "geothermal": { - "constant_temperature_celsius": 65, - "ignore_missing_regions": False, - }, - "river_water": {"constant_temperature_celsius": False}, - }, - description="Dictionary with names of limited heat sources (not air). Must be `river_water` / `geothermal` or another heat source in `Manz et al. 2024 `_.", - ) - direct_utilisation_heat_sources: list[str] = Field( - default_factory=lambda: ["geothermal"], - description="List of heat sources for direct heat utilisation in district heating. Must be in the keys of `heat_utilisation_potentials` (e.g. `geothermal`).", - ) - temperature_limited_stores: list[str] = Field( - default_factory=lambda: ["ptes"], - description="List of names for stores used as limited heat sources.", - ) dh_areas: dict[str, Any] = Field( default_factory=lambda: {"buffer": 1000, "handle_missing_countries": "fill"}, description="District heating areas settings.", ) + geothermal: dict[str, Any] = Field( + default_factory=lambda: {"constant_temperature_celsius": 65}, + description=( + "Geothermal heat source configuration. Settings: " + "'constant_temperature_celsius' specifies the assumed constant " + "temperature of geothermal heat (default 65°C). When geothermal is " + "included in `heat_sources`, this temperature determines whether heat " + "can be used directly (T_source > T_forward) and if not, the ratio for preheating " + "(T_return < T_source < T_forward) and boosting via heat pumps." + ), + ) class _ResidentialHeatDsmConfig(BaseModel): @@ -382,14 +461,46 @@ class SectorConfig(BaseModel): description="District heating configuration.", ) - heat_pump_sources: dict[str, list[str]] = Field( + heat_sources: dict[HeatSystemType, list[HeatSource]] = Field( default_factory=lambda: { - "urban central": ["air"], - "urban decentral": ["air"], - "rural": ["air", "ground"], + HeatSystemType.URBAN_CENTRAL: [ + HeatSource.AIR, + HeatSource.PTES, + HeatSource.GEOTHERMAL, + ], + HeatSystemType.URBAN_DECENTRAL: [HeatSource.AIR], + HeatSystemType.RURAL: [HeatSource.AIR, HeatSource.GROUND], }, - description="Heat pump sources by area.", - ) + description=( + "Heat sources by heat system type. Allowed: " + "urban central: all except 'ground'; " + "urban decentral: ['air']; " + "rural: ['air', 'ground']." + ), + ) + + @field_validator("heat_sources") + @classmethod + def validate_heat_sources_for_system_type( + cls, v: dict[HeatSystemType, list[HeatSource]] + ) -> dict[HeatSystemType, list[HeatSource]]: + """Validate that heat sources are appropriate for each system type.""" + allowed_heat_sources = { + HeatSystemType.URBAN_CENTRAL: [ + s for s in HeatSource if s != HeatSource.GROUND + ], + HeatSystemType.URBAN_DECENTRAL: [HeatSource.AIR], + HeatSystemType.RURAL: [HeatSource.AIR, HeatSource.GROUND], + } + for system_type, sources in v.items(): + allowed = allowed_heat_sources[system_type] + invalid = [s for s in sources if s not in allowed] + if invalid: + raise ValueError( + f"Heat source(s) {[s.value for s in invalid]} not allowed for " + f"'{system_type.value}'. Allowed: {[s.value for s in allowed]}." + ) + return v residential_heat: _ResidentialHeatConfig = Field( default_factory=_ResidentialHeatConfig, @@ -618,9 +729,9 @@ class SectorConfig(BaseModel): default_factory=_RetrofittingConfig, description="Retrofitting configuration." ) - tes: bool = Field( + ttes: bool = Field( True, - description="Add option for storing thermal energy in large water pits associated with district heating systems and individual thermal energy storage (TES).", + description="Enable tank thermal energy storage (TTES) in district heating and individual heating. ", ) boilers: bool = Field( True, description="Add option for transforming gas into heat using gas boilers." diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d9434ef76e..6b5edf6bcb 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -43,6 +43,7 @@ ) from scripts.build_transport_demand import transport_degree_factor from scripts.definitions.heat_sector import HeatSector +from scripts.definitions.heat_source import HeatSource from scripts.definitions.heat_system import HeatSystem from scripts.prepare_network import maybe_adjust_costs_and_potentials @@ -2752,10 +2753,11 @@ def add_heat( n: pypsa.Network, costs: pd.DataFrame, cop_profiles_file: str, - direct_heat_source_utilisation_profile_file: str, + heat_source_direct_utilisation_profile_file: str, + heat_source_preheater_utilisation_profile_file: str, hourly_heat_demand_total_file: str, ptes_e_max_pu_file: str, - ptes_direct_utilisation_profile: str, + ptes_boost_per_discharge_profile_file: str, ates_e_nom_max: str, ates_capex_as_fraction_of_geothermal_heat_source: float, ates_recovery_factor: float, @@ -2806,7 +2808,7 @@ def add_heat( Path to CSV file containing demand-side management profiles for heat params : dict Dictionary containing parameters including: - - heat_pump_sources + - heat_sources - heat_utilisation_potentials - direct_utilisation_heat_sources pop_weighted_energy_totals : pd.DataFrame @@ -2850,7 +2852,19 @@ def add_heat( ) cop = xr.open_dataarray(cop_profiles_file) - direct_heat_profile = xr.open_dataarray(direct_heat_source_utilisation_profile_file) + + heat_source_direct_utilisation_profile = ( + xr.open_dataarray(heat_source_direct_utilisation_profile_file) + if len(heat_source_direct_utilisation_profile_file) > 0 + else None + ) + + heat_source_preheater_utilisation_profile = ( + xr.open_dataarray(heat_source_preheater_utilisation_profile_file) + if len(heat_source_preheater_utilisation_profile_file) > 0 + else None + ) + district_heat_info = pd.read_csv(district_heat_share_file, index_col=0) dist_fraction = district_heat_info["district fraction of node"] urban_fraction = district_heat_info["urban fraction"] @@ -3002,7 +3016,7 @@ def add_heat( logger.info(f"Adding DSM in {heat_system} heating.") - if options["tes"]: + if options["ttes"]: n.add("Carrier", f"{heat_system} water tanks") n.add( @@ -3079,98 +3093,90 @@ def add_heat( ], ) - if heat_system == HeatSystem.URBAN_CENTRAL: - n.add("Carrier", f"{heat_system} water pits") + if ( + heat_system == HeatSystem.URBAN_CENTRAL + and options["district_heating"]["ptes"]["enable"] + ): + n.add("Carrier", f"{heat_system} water pits") - n.add( - "Bus", - nodes + f" {heat_system} water pits", - location=nodes, - carrier=f"{heat_system} water pits", - unit="MWh_th", - ) + n.add( + "Bus", + nodes + f" {heat_system} water pits", + location=nodes, + carrier=f"{heat_system} water pits", + unit="MWh_th", + ) - energy_to_power_ratio_water_pit = costs.at[ - "central water pit storage", "energy to power ratio" - ] + energy_to_power_ratio_water_pit = costs.at[ + "central water pit storage", "energy to power ratio" + ] - n.add( - "Link", - nodes, - suffix=f" {heat_system} water pits charger", - bus0=nodes + f" {heat_system} heat", - bus1=nodes + f" {heat_system} water pits", - efficiency=costs.at[ - "central water pit charger", - "efficiency", - ], - carrier=f"{heat_system} water pits charger", - p_nom_extendable=True, - lifetime=costs.at["central water pit storage", "lifetime"], - marginal_cost=costs.at[ - "central water pit charger", "marginal_cost" - ], - ) + n.add( + "Link", + nodes, + suffix=f" {heat_system} water pits charger", + bus0=nodes + f" {heat_system} heat", + bus1=nodes + f" {heat_system} water pits", + efficiency=costs.at[ + "central water pit charger", + "efficiency", + ], + carrier=f"{heat_system} water pits charger", + p_nom_extendable=True, + lifetime=costs.at["central water pit storage", "lifetime"], + marginal_cost=costs.at["central water pit charger", "marginal_cost"], + ) - if options["district_heating"]["ptes"]["supplemental_heating"][ - "enable" - ]: - ptes_supplemental_heating_required = ( - xr.open_dataarray(ptes_direct_utilisation_profile) - .sel(name=nodes) - .to_pandas() - .reindex(index=n.snapshots) - ) - else: - ptes_supplemental_heating_required = 1 + n.add( + "Bus", + HeatSource.PTES.resource_bus(nodes, heat_system), + location=nodes, + carrier=f"{heat_system} ptes heat", + unit="MWh_th", + ) - n.add( - "Link", - nodes, - suffix=f" {heat_system} water pits discharger", - bus0=nodes + f" {heat_system} water pits", - bus1=nodes + f" {heat_system} heat", - carrier=f"{heat_system} water pits discharger", - efficiency=costs.at[ - "central water pit discharger", - "efficiency", - ] - * ptes_supplemental_heating_required, - p_nom_extendable=True, - lifetime=costs.at["central water pit storage", "lifetime"], - ) - n.links.loc[ - nodes + f" {heat_system} water pits charger", - "energy to power ratio", - ] = energy_to_power_ratio_water_pit - - if options["district_heating"]["ptes"]["dynamic_capacity"]: - # Load pre-calculated e_max_pu profiles - e_max_pu_data = xr.open_dataarray(ptes_e_max_pu_file) - e_max_pu = ( - e_max_pu_data.sel(name=nodes) - .to_pandas() - .reindex(index=n.snapshots) - ) - else: - e_max_pu = 1 + n.add( + "Link", + nodes, + suffix=f" {heat_system} water pits discharger", + bus0=nodes + f" {heat_system} water pits", + bus1=HeatSource.PTES.resource_bus(nodes, heat_system), + carrier=f"{heat_system} water pits discharger", + efficiency=costs.at[ + "central water pit discharger", + "efficiency", + ], + p_nom_extendable=True, + lifetime=costs.at["central water pit storage", "lifetime"], + ) + n.links.loc[ + nodes + f" {heat_system} water pits charger", + "energy to power ratio", + ] = energy_to_power_ratio_water_pit - n.add( - "Store", - nodes, - suffix=f" {heat_system} water pits", - bus=nodes + f" {heat_system} water pits", - e_cyclic=True, - e_nom_extendable=True, - e_max_pu=e_max_pu, - carrier=f"{heat_system} water pits", - standing_loss=costs.at[ - "central water pit storage", "standing_losses" - ] - / 100, # convert %/hour into unit/hour - capital_cost=costs.at["central water pit storage", "capital_cost"], - lifetime=costs.at["central water pit storage", "lifetime"], + if options["district_heating"]["ptes"]["temperature_dependent_capacity"]: + # Load pre-calculated e_max_pu profiles + e_max_pu_data = xr.open_dataarray(ptes_e_max_pu_file) + e_max_pu = ( + e_max_pu_data.sel(name=nodes).to_pandas().reindex(index=n.snapshots) ) + else: + e_max_pu = 1 + + n.add( + "Store", + nodes, + suffix=f" {heat_system} water pits", + bus=nodes + f" {heat_system} water pits", + e_cyclic=True, + e_nom_extendable=True, + e_max_pu=e_max_pu, + carrier=f"{heat_system} water pits", + standing_loss=costs.at["central water pit storage", "standing_losses"] + / 100, # convert %/hour into unit/hour + capital_cost=costs.at["central water pit storage", "capital_cost"], + lifetime=costs.at["central water pit storage", "lifetime"], + ) if enable_ates and heat_system == HeatSystem.URBAN_CENTRAL: n.add("Carrier", f"{heat_system} aquifer thermal energy storage") @@ -3227,190 +3233,214 @@ def add_heat( ) ## Add heat pumps - for heat_source in params.heat_pump_sources[heat_system.system_type.value]: + for heat_source in params.heat_sources[heat_system.system_type.value]: + # Convert string to HeatSource enum + heat_source = HeatSource(heat_source) + 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, + heat_source=heat_source.value, name=nodes, ) + .transpose("time", "name") .to_pandas() - .reindex(index=n.snapshots) if options["time_dep_hp_cop"] else costs.loc[[costs_name_heat_pump], ["efficiency"]] ) - if heat_source in params.limited_heat_sources: - # get potential - p_max_source = pd.read_csv( - heat_source_profile_files[heat_source], - index_col=0, - parse_dates=True, - ).squeeze()[nodes] - - # if only dimension is nodes, convert series to dataframe with columns as nodes and index as snapshots - if p_max_source.ndim == 1: - p_max_source = pd.DataFrame( - [p_max_source] * len(n.snapshots), - index=n.snapshots, - columns=nodes, - ) + heat_carrier = heat_source.heat_carrier(heat_system) - # add resource - heat_carrier = f"{heat_system} {heat_source} heat" + if heat_source.requires_bus: + # add heat source carrier and bus n.add("Carrier", heat_carrier) n.add( "Bus", - nodes, + heat_source.resource_bus(nodes, heat_system), location=nodes, - suffix=f" {heat_carrier}", carrier=heat_carrier, ) - # TODO: implement better handling of zero-cost heat sources - try: - capital_cost = ( - costs.at[ - heat_system.heat_source_costs_name(heat_source), - "capital_cost", - ] - * overdim_factor + preheater_utilisation_profile = ( + heat_source_preheater_utilisation_profile.sel( + heat_source=heat_source.value, name=nodes ) - lifetime = costs.at[ - heat_system.heat_source_costs_name(heat_source), "lifetime" - ] - except KeyError: - logger.warning( - f"Heat source {heat_source} not found in cost data. Assuming zero cost and infinite lifetime." + .transpose("time", "name") + .to_pandas() + ) + + n.add( + "Bus", + heat_source.preheater_input_bus(nodes, heat_system), + location=nodes, + carrier=heat_source.preheater_input_carrier(heat_system), + ) + + n.add( + "Bus", + heat_source.get_heat_pump_input_bus(nodes, heat_system), + location=nodes, + carrier=heat_source.heat_pump_input_carrier(heat_system), + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat preheater", + bus0=heat_source.preheater_input_bus(nodes, heat_system), + bus1=nodes + f" {heat_system} heat", + bus2=heat_source.get_heat_pump_input_bus(nodes, heat_system), + efficiency=preheater_utilisation_profile, + efficiency2=1 - preheater_utilisation_profile, + carrier=f"{heat_system} {heat_source} heat preheater", + p_nom_extendable=True, + ) + + direct_utilisation_profile = ( + heat_source_direct_utilisation_profile.sel( + heat_source=heat_source.value, name=nodes ) - capital_cost = 0.0 - lifetime = np.inf + .transpose("time", "name") + .to_pandas() + ) + + # add link for direct usage of heat source when source temperature exceeds forward temperature + + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat utilisation", + bus0=heat_source.resource_bus(nodes, heat_system), + bus1=nodes + f" {heat_system} heat", + bus2=heat_source.preheater_input_bus(nodes, heat_system), + efficiency=direct_utilisation_profile, + efficiency2=1 - direct_utilisation_profile, + carrier=f"{heat_system} {heat_source} heat utilisation", + p_nom_extendable=True, + ) + + if heat_source.requires_generator: + # Load time-varying heat source power (MW) + heat_source_power = pd.read_csv( + heat_source_profile_files[heat_source.value], + index_col=0, + parse_dates=True, + )[nodes] + + # p_nom_max is the maximum available capacity across all timesteps + p_nom_max = heat_source_power.max() + + # p_max_pu is the per-unit availability (0-1) relative to p_nom_max + p_max_pu = heat_source_power / p_nom_max + + capital_cost = heat_source.get_capital_cost( + costs, overdim_factor, heat_system + ) + lifetime = heat_source.get_lifetime(costs, heat_system) n.add( "Generator", nodes, suffix=f" {heat_carrier}", - bus=nodes + f" {heat_carrier}", + bus=heat_source.resource_bus(nodes, heat_system), carrier=heat_carrier, p_nom_extendable=True, + p_nom_max=p_nom_max, capital_cost=capital_cost, lifetime=lifetime, - p_nom_max=p_max_source.max(), - p_max_pu=p_max_source / p_max_source.max(), + p_max_pu=p_max_pu, ) - # add heat pump converting source heat + electricity to urban central heat + + if heat_source.requires_heat_pump( + ptes_discharge_resistive_boosting=params.sector["district_heating"][ + "ptes" + ]["discharge_resistive_boosting"] + ): n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat pump", bus0=nodes + f" {heat_system} heat", bus1=nodes, - bus2=nodes + f" {heat_carrier}", + bus2=heat_source.get_heat_pump_input_bus(nodes, heat_system), carrier=f"{heat_system} {heat_source} heat pump", - efficiency=(1 / cop_heat_pump.clip(lower=0.001)).squeeze(), - efficiency2=(1 - (1 / cop_heat_pump.clip(lower=0.001))).squeeze(), + efficiency=1 / cop_heat_pump.clip(lower=0.001).squeeze(), + efficiency2=heat_source.get_heat_pump_efficiency2(cop_heat_pump), capital_cost=costs.at[costs_name_heat_pump, "capital_cost"] * overdim_factor, - p_nom_extendable=True, - p_min_pu=( - -cop_heat_pump / cop_heat_pump.clip(lower=0.001) - ).squeeze(), + p_min_pu=-(cop_heat_pump > 0).squeeze().astype(float), p_max_pu=0, + p_nom_extendable=True, lifetime=costs.at[costs_name_heat_pump, "lifetime"], ) - if heat_source in params.direct_utilisation_heat_sources: - # 1 if source temperature exceeds forward temperature, 0 otherwise: - efficiency_direct_utilisation = ( - direct_heat_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.add( - "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, - ) + if options["resistive_heaters"]: + ptes_heat_source = HeatSource.PTES + key = f"{heat_system.central_or_decentral} resistive heater" if ( - not options["district_heating"]["ptes"]["supplemental_heating"][ - "enable" - ] - and options["district_heating"]["ptes"]["supplemental_heating"][ - "booster_heat_pump" + heat_system == HeatSystem.URBAN_CENTRAL + and params.sector["district_heating"]["ptes"]["enable"] == True + and params.sector["district_heating"]["ptes"][ + "discharge_resistive_boosting" ] ): - raise ValueError( - "'booster_heat_pump' is true, but 'enable' is false in 'supplemental_heating'." + ptes_boost_per_discharge_profiles = ( + xr.open_dataarray(ptes_boost_per_discharge_profile_file) + .sel(name=nodes) + .to_pandas() + .reindex(index=n.snapshots) + ) + + n.add( + "Bus", + nodes, + location=nodes, + suffix=f" {heat_system} resistive heat", + carrier=f"{heat_system} resistive heat", ) - if ( - heat_source in params.temperature_limited_stores - and options["district_heating"]["ptes"]["supplemental_heating"][ - "enable" - ] - and options["district_heating"]["ptes"]["supplemental_heating"][ - "booster_heat_pump" - ] - ): n.add( "Link", nodes, - suffix=f" {heat_system} {heat_source} heat pump", + suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", - bus1=nodes, - bus2=nodes + f" {heat_system} water pits", - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=(1 / (cop_heat_pump - 1).clip(lower=0.001)).squeeze(), - efficiency2=(1 - 1 / cop_heat_pump.clip(lower=0.001)).squeeze(), - capital_cost=costs.at[costs_name_heat_pump, "capital_cost"] - * overdim_factor, + bus1=nodes + f" {heat_system} resistive heat", + bus2=ptes_heat_source.preheater_input_bus(nodes, heat_system), + # eff = 1 - eff2 (energy conservation) + efficiency=ptes_boost_per_discharge_profiles + / (ptes_boost_per_discharge_profiles + 1), + # Use 1 unit of medium-temperature heat to produce (ptes_boost_per_discharge_profiles + 1) units of district heating + # (similar to HP balance: p_el x COP = p_source + p_el ) + efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), p_nom_extendable=True, - p_min_pu=( - -cop_heat_pump / cop_heat_pump.clip(lower=0.001) - ).squeeze(), + p_min_pu=-(ptes_boost_per_discharge_profiles > 0).astype(float), + carrier=f"{heat_system} water pits resistive booster", p_max_pu=0, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], ) - else: n.add( "Link", nodes, - suffix=f" {heat_system} {heat_source} heat pump", - bus0=nodes + f" {heat_system} heat", - bus1=nodes, - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=(1 / cop_heat_pump.clip(lower=0.001)).squeeze(), - capital_cost=costs.at[costs_name_heat_pump, "capital_cost"] - * overdim_factor, - p_min_pu=( - -cop_heat_pump / cop_heat_pump.clip(lower=0.001) - ).squeeze(), - p_max_pu=0, + suffix=f" {heat_system} resistive heater stand-alone", + bus0=nodes + f" {heat_system} resistive heat", + bus1=nodes + f" {heat_system} heat", + carrier=f"{heat_system} resistive heat stand-alone", + efficiency=1.0, p_nom_extendable=True, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], ) - if options["resistive_heaters"]: - key = f"{heat_system.central_or_decentral} resistive heater" + resistive_heater_bus1 = nodes + f" {heat_system} resistive heat" + else: + resistive_heater_bus1 = nodes + f" {heat_system} heat" n.add( "Link", nodes + f" {heat_system} resistive heater", bus0=nodes, - bus1=nodes + f" {heat_system} heat", + bus1=resistive_heater_bus1, carrier=f"{heat_system} resistive heater", efficiency=costs.at[key, "efficiency"], capital_cost=costs.at[key, "efficiency"] @@ -6386,7 +6416,8 @@ def add_import_options( n=n, costs=costs, cop_profiles_file=snakemake.input.cop_profiles, - direct_heat_source_utilisation_profile_file=snakemake.input.direct_heat_source_utilisation_profiles, + heat_source_direct_utilisation_profile_file=snakemake.input.heat_source_direct_utilisation_profiles, + heat_source_preheater_utilisation_profile_file=snakemake.input.heat_source_preheater_utilisation_profiles, hourly_heat_demand_total_file=snakemake.input.hourly_heat_demand_total, ptes_e_max_pu_file=snakemake.input.ptes_e_max_pu_profiles, ates_e_nom_max=snakemake.input.ates_potentials, @@ -6400,15 +6431,15 @@ def add_import_options( "recovery_factor" ], enable_ates=snakemake.params.sector["district_heating"]["ates"]["enable"], - ptes_direct_utilisation_profile=snakemake.input.ptes_direct_utilisation_profiles, + ptes_boost_per_discharge_profile_file=snakemake.input.ptes_boost_per_discharge_profiles, district_heat_share_file=snakemake.input.district_heat_share, solar_thermal_total_file=snakemake.input.solar_thermal_total, retro_cost_file=snakemake.input.retro_cost, floor_area_file=snakemake.input.floor_area, heat_source_profile_files={ source: snakemake.input[source] - for source in snakemake.params.limited_heat_sources - if source in snakemake.input.keys() + for source in snakemake.params.heat_sources["urban central"] + if HeatSource(source).requires_generator }, heat_dsm_profile_file=snakemake.input.heat_dsm_profile, params=snakemake.params, diff --git a/scripts/solve_network.py b/scripts/solve_network.py index 0fa8e90fc9..aa2b3c5923 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1217,7 +1217,7 @@ def extra_functionality( ): add_solar_potential_constraints(n, config) - if n.config.get("sector", {}).get("tes", False): + if n.config.get("sector", {}).get("ttes", False): if n.buses.index.str.contains( r"urban central heat|urban decentral heat|rural heat", case=False,