From 63e06c556b827b97538bb5cd836b5e84697475ad Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 10 Nov 2025 12:22:15 +0100 Subject: [PATCH 01/65] refactor:introduct heat-source enum and pre-heating --- scripts/definitions/heat_source.py | 309 +++++++++++++++++++++++++++++ scripts/prepare_sector_network.py | 236 ++++++++++------------ 2 files changed, 409 insertions(+), 136 deletions(-) create mode 100644 scripts/definitions/heat_source.py diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py new file mode 100644 index 0000000000..724fb7f533 --- /dev/null +++ b/scripts/definitions/heat_source.py @@ -0,0 +1,309 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +from enum import Enum + + +class HeatSource(Enum): + """ + Enumeration representing different heat sources for heat pumps. + + Attributes + ---------- + GEOTHERMAL : str + Geothermal heat source. + RIVER_WATER : str + River water heat source. + SEA_WATER : str + Sea water heat source. + AIR : str + Air heat source. + GROUND : str + Ground heat source. + PTES: str + Pit Thermal Energy Storage as heat source. + + Methods + ------- + __str__() + Returns the string representation of the heat source. + constant_temperature_celsius() + Returns the constant temperature in Celsius for the heat source. + is_limited() + Returns whether the heat source is limited (vs. inexhaustible). + requires_generator() + Returns whether the heat source requires a generator. + requires_preheater() + Returns whether the heat source requires a preheater. + supports_direct_utilisation() + Returns whether the heat source supports direct heat utilisation. + get_capital_cost(costs, overdim_factor, heat_system) + Returns the capital cost for the heat source generator. + get_lifetime(costs, heat_system) + Returns the lifetime for the heat source generator. + get_heat_pump_bus2(nodes, heat_system, heat_carrier) + Returns the bus2 configuration for the heat pump link. + get_heat_pump_efficiency2(cop_heat_pump) + Returns the efficiency2 for the heat pump link. + get_efficiency_pre_heater(efficiency_direct_utilisation) + Returns the efficiency for the preheater link. + get_efficiency_direct_utilisation(direct_heat_profile, nodes, n) + Returns the efficiency for direct heat utilisation. + """ + + 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 constant_temperature_celsius(self) -> float | bool: + """ + Returns the constant temperature in Celsius for the heat source. + + Returns + ------- + float | None + The constant temperature in Celsius, or None if not applicable. + """ + if self == HeatSource.GEOTHERMAL: + return 65 + else: + return False + + @property + def is_limited(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.SEA_WATER, + HeatSource.PTES, + ]: + return True + else: + return False + + @property + def requires_generator(self) -> bool: + """ + Returns whether the heat source requires a generator. + + Returns + ------- + bool + True if the heat source requires a generator, False otherwise. + """ + if self in [HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER]: + return True + else: + return False + + @property + def requires_preheater(self) -> bool: + """ + Returns whether the heat source requires a preheater. + + Returns + ------- + bool + True if the heat source requires a preheater, False otherwise. + """ + if self in [HeatSource.GEOTHERMAL, HeatSource.PTES]: + return True + else: + return False + + 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. + """ + # For direct utilisation heat sources, get cost from technology-data + # For other limited sources (like river_water without direct utilisation), return 0.0 + # For inexhaustible sources, this method shouldn't be called + if self.supports_direct_utilisation: + return ( + costs.at[ + heat_system.heat_source_costs_name(str(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. + """ + # For direct utilisation heat sources, get lifetime from technology-data + # For other limited sources (like river_water without direct utilisation), return np.inf + # For inexhaustible sources, this method shouldn't be called + if self.supports_direct_utilisation: + return costs.at[heat_system.heat_source_costs_name(str(self)), "lifetime"] + else: + return float("inf") + + def get_heat_pump_bus2(self, nodes, heat_system, heat_carrier: str) -> str: + """ + Returns the bus2 configuration for the heat pump link. + + Parameters + ---------- + nodes : pd.Index or list + The nodes for which to generate the bus name. + heat_system : HeatSystem + The heat system. + heat_carrier : str + The heat carrier name. + + Returns + ------- + str + The bus2 name for the heat pump, or empty string if not applicable. + """ + if not self.is_limited: + # Inexhaustible sources (air, ground) don't have a bus2 + return "" + elif self.requires_preheater: + # Sources with preheater use pre-chilled bus + return str(nodes) + f" {heat_carrier} pre-chilled" + else: + # Limited sources without preheater use the heat carrier bus directly + return str(nodes) + f" {heat_carrier}" + + def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: + """ + Returns the efficiency2 for the heat pump link. + + Parameters + ---------- + cop_heat_pump : float or pd.Series + The coefficient of performance of the heat pump. + + Returns + ------- + float or pd.Series + The efficiency2 value for the heat pump. + """ + if not self.is_limited: + # Inexhaustible sources (air, ground, sea_water) have efficiency2 = 1 + # (no resource consumption from dummy bus) + return 1.0 + else: + # Limited heat sources consume heat from the resource bus (either pre-chilled or direct) + # efficiency2 represents the fraction of heat drawn from the source + # This is 1 - (1/COP), representing (COP-1)/COP + return 1 - (1 / cop_heat_pump.clip(lower=0.001)) + + def get_efficiency_pre_heater(self, efficiency_direct_utilisation) -> float: + """ + Returns the efficiency for the preheater link. + + Parameters + ---------- + efficiency_direct_utilisation : float or pd.Series + The efficiency of direct heat utilisation. + + Returns + ------- + float or pd.Series + The efficiency for the preheater (1 - efficiency_direct_utilisation). + """ + if not self.requires_preheater: + raise ValueError( + f"Heat source {self} does not require a preheater. " + "This method should only be called for sources that support direct utilisation." + ) + # The preheater efficiency is the complement of direct utilisation efficiency + # When direct utilisation is high (source temp > forward temp), preheater efficiency is low + # This represents the fraction of heat that goes through the heat pump vs. direct use + return 1 - efficiency_direct_utilisation + + def get_efficiency_direct_utilisation(self, direct_heat_profile, nodes, n) -> float: + """ + Returns the efficiency for direct heat utilisation. + + Parameters + ---------- + direct_heat_profile : xr.DataArray + DataArray containing direct heat utilisation profiles. + nodes : pd.Index or list + The nodes for which to get the efficiency. + n : pypsa.Network + The PyPSA network object (for accessing snapshots). + + Returns + ------- + float or pd.Series + The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). + """ + if not self.supports_direct_utilisation: + raise ValueError( + f"Heat source {self} does not support direct utilisation. " + "This method should only be called for sources in direct_utilisation_heat_sources." + ) + # Extract the efficiency profile from the data + # This is a binary or continuous value indicating when/how much heat + # can be directly used (1 if source temperature > forward temperature, 0 otherwise) + return ( + direct_heat_profile.sel( + heat_source=str(self), + name=nodes, + ) + .to_pandas() + .reindex(index=n.snapshots) + ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 05a92083dc..9831b1d6c2 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -42,6 +42,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 @@ -3065,30 +3066,25 @@ def add_heat( ], ) - 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", + nodes + f" {heat_system} water pits discharged", + location=nodes, + carrier=f"{heat_system} water pits discharged", + 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", + bus1=nodes + f" {heat_system} water pits discharged", 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"], ) @@ -3181,6 +3177,9 @@ def add_heat( ## Add heat pumps for heat_source in params.heat_pump_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 = ( @@ -3195,22 +3194,7 @@ def add_heat( else costs.at[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, - ) - + if heat_source.is_limited: # add resource heat_carrier = f"{heat_system} {heat_source} heat" n.add("Carrier", heat_carrier) @@ -3222,66 +3206,75 @@ def add_heat( 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 - ) - 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." + # Check if heat source requires a separate generator + if heat_source.requires_generator: + # Standard heat source with potential file and generator + p_max_source = pd.read_csv( + heat_source_profile_files[heat_source], + index_col=0, + ).squeeze()[nodes] + + # Get capital cost and lifetime from enum + capital_cost = heat_source.get_capital_cost( + costs, overdim_factor, heat_system ) - capital_cost = 0.0 - lifetime = np.inf + lifetime = heat_source.get_lifetime(costs, heat_system) - n.add( - "Generator", - nodes, - suffix=f" {heat_carrier}", - bus=nodes + f" {heat_carrier}", - carrier=heat_carrier, - p_nom_extendable=True, - capital_cost=capital_cost, - lifetime=lifetime, - p_nom_max=p_max_source.max(), - p_max_pu=p_max_source / p_max_source.max(), - ) - # add heat pump converting source heat + electricity to urban central heat - 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}", - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=(1 / cop_heat_pump.clip(lower=0.001)), - efficiency2=1 - (1 / cop_heat_pump.clip(lower=0.001)), - 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), - p_max_pu=0, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], - ) + n.add( + "Generator", + nodes, + suffix=f" {heat_carrier}", + bus=nodes + f" {heat_carrier}", + carrier=heat_carrier, + p_nom_extendable=True, + capital_cost=capital_cost, + lifetime=lifetime, + p_max_pu=p_max_source, + ) - if heat_source in params.direct_utilisation_heat_sources: - # 1 if source temperature exceeds forward temperature, 0 otherwise: + # Get direct utilisation efficiency if applicable + if heat_source.supports_direct_utilisation: efficiency_direct_utilisation = ( - direct_heat_profile.sel( - heat_source=heat_source, - name=nodes, + heat_source.get_efficiency_direct_utilisation( + direct_heat_profile, nodes, n ) - .to_pandas() - .reindex(index=n.snapshots) ) + + # add heat pump converting source heat + electricity to urban central heat + if heat_source.requires_preheater: + n.add( + "Bus", + nodes, + location=nodes, + suffix=f" {heat_carrier}", + carrier=f"{heat_carrier} pre-chilled", + ) + + efficiency_preheater = heat_source.get_efficiency_pre_heater( + efficiency_direct_utilisation + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat preheater", + bus0=nodes + f" {heat_carrier}", + bus1=nodes + f" {heat_system} heat", + bus2=nodes + f" {heat_system} {heat_carrier} pre-chilled", + efficiency=efficiency_preheater, + carrier=f"{heat_system} {heat_source} heat preheater", + p_nom_extendable=True, + ) + + # Determine bus2 and efficiency2 for heat pump + bus2_heat_pump = heat_source.get_heat_pump_bus2( + nodes, heat_system, heat_carrier + ) + efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2( + cop_heat_pump + ) + + if heat_source.supports_direct_utilisation: # add link for direct usage of heat source when source temperature exceeds forward temperature n.add( "Link", @@ -3294,61 +3287,32 @@ def add_heat( p_nom_extendable=True, ) - if ( - not options["district_heating"]["ptes"]["supplemental_heating"][ - "enable" - ] - and options["district_heating"]["ptes"]["supplemental_heating"][ - "booster_heat_pump" - ] - ): - raise ValueError( - "'booster_heat_pump' is true, but 'enable' is false in 'supplemental_heating'." + else: + # heat source is inexhaustible (air or ground) + bus2_heat_pump = heat_source.get_heat_pump_bus2( + nodes, heat_system, heat_carrier ) - - 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", - 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)), - efficiency2=1 - 1 / cop_heat_pump.clip(lower=0.001), - 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), - p_max_pu=0, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], + efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2( + cop_heat_pump ) - 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), - 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), - p_max_pu=0, - p_nom_extendable=True, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], - ) + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat pump", + bus0=nodes + f" {heat_system} heat", + bus1=nodes, + bus2=bus2_heat_pump, + carrier=f"{heat_system} {heat_source} heat pump", + efficiency=1 / cop_heat_pump.clip(lower=0.001), + efficiency2=efficiency2_heat_pump, + 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), + p_max_pu=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" From a2fed2d8393e527a98069962f218792663c80012 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 10 Nov 2025 13:27:04 +0100 Subject: [PATCH 02/65] refactor:update PTES boosting rule --- .../ptes_temperature_approximator.py | 209 +++++++++++++++--- scripts/build_ptes_operations/run.py | 81 ++++--- 2 files changed, 222 insertions(+), 68 deletions(-) diff --git a/scripts/build_ptes_operations/ptes_temperature_approximator.py b/scripts/build_ptes_operations/ptes_temperature_approximator.py index 51197eff1b..fc4e5353c7 100644 --- a/scripts/build_ptes_operations/ptes_temperature_approximator.py +++ b/scripts/build_ptes_operations/ptes_temperature_approximator.py @@ -2,14 +2,37 @@ # # SPDX-License-Identifier: MIT +from enum import Enum + import xarray as xr +class TesTemperatureMode(Enum): + """ + TES temperature profile assumptions. + + CONSTANT: Assumes fixed temperatures at operational limits. + - Top temperature: constant at max_top_temperature + - Bottom temperature: constant at min_bottom_temperature + - Assumes charge-boosting to maintain top temperature. + - NOTE: Assuming bottom_temperature = min_bottom_temperature ignores that cooling of the return temperature might be necessary in practice. + + DYNAMIC: Assumes temperatures follow network conditions. + - Top temperature: follows forward_temperature (clipped at max_top_temperature) + - Bottom temperature: follows return_temperature + - Does not assume charge-boosting. + - Note: This ignores that the TES temperatures do not match the supply temperatures due to thermal losses or other factors. + """ + + CONSTANT = "constant" + DYNAMIC = "dynamic" + + 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 +41,30 @@ 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. + max_top_temperature : float + Maximum operational temperature of top layer in PTES. + min_bottom_temperature : float + Minimum operational temperature of bottom layer in PTES. + temperature_profile : TesTemperatureProfile + TES temperature profile assumption. + charge_boosting_required : bool + Whether charge boosting is required/allowed. + discharge_boosting_required : bool + Whether discharge boosting is required/allowed. + dynamic_capacity : bool + Whether storage capacity varies with temperature. If False, assumes constant capacity. """ def __init__( self, forward_temperature: xr.DataArray, return_temperature: xr.DataArray, - max_ptes_top_temperature: float = 90, - min_ptes_bottom_temperature: float = 35, + max_top_temperature: float, + min_bottom_temperature: float, + temperature_profile: TesTemperatureMode, + charge_boosting_required: bool, + discharge_boosting_required: bool, + dynamic_capacity: bool, ): """ Initialize PtesTemperatureApproximator. @@ -40,69 +75,175 @@ 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. + max_top_temperature : float + Maximum operational temperature of top layer in PTES. + min_bottom_temperature : float + Minimum operational temperature of bottom layer in PTES. + temperature_profile : TesTemperatureProfile + TES temperature profile assumption. + charge_boosting_required : bool + Whether charge boosting is required/allowed. + discharge_boosting_required : bool + Whether discharge boosting is required/allowed. + dynamic_capacity : bool + Whether storage capacity varies with temperature. If False, assumes constant capacity. """ 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.max_top_temperature = max_top_temperature + self.min_bottom_temperature = min_bottom_temperature + self.temperature_profile = temperature_profile + self.charge_boosting_required = charge_boosting_required + self.discharge_boosting_required = discharge_boosting_required + self.dynamic_capacity = dynamic_capacity @property def top_temperature(self) -> xr.DataArray: """ - Forward temperature clipped at the maximum PTES temperature. + Forward temperature clipped at the maximum PTES temperature or constant max temperature. 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.temperature_profile == TesTemperatureMode.CONSTANT: + return xr.full_like(self.forward_temperature, self.max_top_temperature) + elif self.temperature_profile == TesTemperatureMode.DYNAMIC: + return self.forward_temperature.where( + self.forward_temperature <= self.max_top_temperature, + self.max_top_temperature, + ) + else: + raise NotImplementedError( + f"Temperature profile {self.temperature_profile} not implemented" + ) @property def bottom_temperature(self) -> xr.DataArray: """ - Return temperature clipped at the minimum PTES temperature. + Return temperature clipped at the minimum PTES temperature or constant min temperature. Returns ------- xr.DataArray The resulting bottom temperature profile for PTES. """ - return self.min_ptes_bottom_temperature + if self.temperature_profile == TesTemperatureMode.CONSTANT: + return xr.full_like(self.return_temperature, self.min_bottom_temperature) + elif self.temperature_profile == TesTemperatureMode.DYNAMIC: + return self.return_temperature + else: + raise NotImplementedError( + f"Temperature profile {self.temperature_profile} not implemented" + ) @property - def direct_utilisation_profile(self) -> xr.DataArray: + def e_max_pu(self) -> xr.DataArray: """ - Identify timesteps requiring supplemental heating. + Calculate the normalized delta T for TES capacity in relation to + max and min temperature. 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 percentage of maximum capacity. + If dynamic_capacity is False, returns constant capacity of 1.0. """ - return (self.forward_temperature <= self.max_ptes_top_temperature).astype(int) + if self.dynamic_capacity: + delta_t = self.top_temperature - self.bottom_temperature + normalized_delta_t = delta_t / ( + self.max_top_temperature - self.min_bottom_temperature + ) + return normalized_delta_t.clip(min=0) # Ensure non-negative values + else: + 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: + return ( + (self.forward_temperature - self.top_temperature) + / (self.top_temperature - self.bottom_temperature) + ).where(self.forward_temperature > self.top_temperature, 0) + else: + return xr.zeros_like(self.forward_temperature) + + @property + def boost_per_charge(self) -> xr.DataArray: + """ + Calculate how much of the total energy needed to fill the PTES to its + maximum capacity has already been delivered by charging up to the forward + temperature, versus how much extra energy remains to reach the maximum. + + Notes + ----- + To fill the storage from the return temperature all the way up to its + maximum 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_forward is the energy already delivered by charging to the forward setpoint. + - Q_boosting is the extra boost energy still needed to reach maximum capacity. + + Defining α as the ratio of delivered energy to remaining boost energy: + + α = Q_boost / Q_charge + = (T_top − T_forward) / + (T_forward − T_return) + + This ratio quantifies the share of the total charge process that has + already been completed (via Q_forward) relative to what is still + required (Q_boosting) to hit the maximum PTES top temperature. + + Wherever the forward temperature meets or exceeds the maximum, α is set + to zero since no further boost is needed. + + Returns + ------- + xr.DataArray + The fraction of the PTES's available storage capacity already used. """ - 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: + return ( + ( + (self.max_top_temperature - self.forward_temperature) + / (self.forward_temperature - self.return_temperature) + ) + .where(self.forward_temperature < self.max_top_temperature, 0) + .clip(max=1) + ) + else: + 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..0b26ea6108 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -5,10 +5,6 @@ Approximate the top temperature of the pit thermal energy storage (PTES), ensuring that the temperature does not exceed the operational limit. -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. - 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). @@ -23,10 +19,12 @@ sector district_heating: ptes: - dynamic_ptes_capacity: - supplemental_heating: - enable: + dynamic_capacity: + discharge_boosting_required: + charge_boosting_required: + temperature_profile max_top_temperature: + min_bottom_temperature: Inputs ------ @@ -34,15 +32,19 @@ Forward temperature profiles for the district heating networks. - `resources//central_heating_return_temperature_profiles.nc`: Return temperature profiles for the district heating networks. +- `resources//ptes_temperature_boost_ratio_profiles.nc` + Ratio of PTES charge that requires additional heating due to temperature differences. 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. +- `ptes_temperature_boost_ratio_profiles` (netCDF): + Charging temperature boost ratio time series. +- `ptes_forward_temperature_boost_ratio_profiles` (netCDF): + Forward flow temperature boost ratio time series. Source ------ @@ -53,17 +55,18 @@ 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, + TesTemperatureMode, ) logger = logging.getLogger(__name__) 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,10 +76,26 @@ set_scenario_config(snakemake) + if ( + snakemake.params.charge_boosting_required + and TesTemperatureMode(snakemake.params.ptes_temperature_profile) + is TesTemperatureMode.DYNAMIC + ): + raise ValueError( + "Charger boosting cannot be used with 'dynamic' temperature profile" + ) + # Load temperature profiles logger.info( - "Loading district heating temperature profiles and constructing PTES temperature approximator" + "Loading district heating temperature profiles and approximating PTES temperatures" + ) + logger.info( + f"PTES configuration: temperature_profile={snakemake.params.ptes_temperature_profile}, " + f"charge_boosting_required={snakemake.params.charge_boosting_required}, " + f"discharge_boosting_required={snakemake.params.discharge_boosting_required}, " + f"dynamic_capacity={snakemake.params.dynamic_capacity}" ) + # Initialize unified PTES temperature class ptes_temperature_approximator = PtesTemperatureApproximator( forward_temperature=xr.open_dataarray( @@ -85,34 +104,28 @@ 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, + max_top_temperature=snakemake.params.max_ptes_top_temperature, + min_bottom_temperature=snakemake.params.min_ptes_bottom_temperature, + temperature_profile=TesTemperatureMode( + snakemake.params.ptes_temperature_profile + ), + charge_boosting_required=snakemake.params.charge_boosting_required, + discharge_boosting_required=snakemake.params.discharge_boosting_required, + dynamic_capacity=snakemake.params.dynamic_capacity, ) - # 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( - snakemake.output.ptes_top_temperature_profiles + snakemake.output.ptes_top_temperature_profile ) - # 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.e_max_pu.to_netcdf( + snakemake.output.ptes_e_max_pu_profile ) - # 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.boost_per_discharge.to_netcdf( + snakemake.output.boost_per_discharge_profile ) - ptes_temperature_approximator.e_max_pu.to_netcdf( - snakemake.output.ptes_e_max_pu_profiles + + ptes_temperature_approximator.boost_per_charge.to_netcdf( + snakemake.output.boost_per_charge_profile ) From 942e703cb330a5b0d39334134385b3ce104638af Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 10 Nov 2025 18:35:19 +0100 Subject: [PATCH 03/65] update workflow --- config/config.default.yaml | 9 +-- config/plotting.default.yaml | 2 + rules/build_sector.smk | 43 ++++++---- ...build_heat_source_utilisation_profiles.py} | 59 ++++++++++++-- scripts/definitions/heat_source.py | 81 +++++++++---------- scripts/definitions/heat_system.py | 33 +++++--- scripts/prepare_sector_network.py | 81 +++++++++---------- 7 files changed, 182 insertions(+), 126 deletions(-) rename scripts/{build_direct_heat_source_utilisation_profiles.py => build_heat_source_utilisation_profiles.py} (59%) diff --git a/config/config.default.yaml b/config/config.default.yaml index f2f6b34915..6c6cfc3aee 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -520,10 +520,9 @@ sector: rolling_window_ambient_temperature: 72 relative_annual_temperature_reduction: 0.01 ptes: - dynamic_capacity: true - supplemental_heating: - enable: false - booster_heat_pump: false + dynamic_capacity: false + charge_boosting_required: false + discharge_boosting_required: false max_top_temperature: 90 min_bottom_temperature: 35 ates: @@ -557,7 +556,7 @@ sector: dh_areas: buffer: 1000 handle_missing_countries: fill - heat_pump_sources: + heat_sources: urban central: - air urban decentral: diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index 5346f09258..d37fef4ef4 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -457,6 +457,7 @@ plotting: services rural air heat pump: '#5af95d' urban central air heat pump: '#6cfb6b' ptes heat pump: '#5dade2' + ptes preheater: '#5dade2' urban central ptes heat pump: '#3498db' urban central geothermal heat pump: '#4f2144' geothermal heat pump: '#4f2144' @@ -566,6 +567,7 @@ plotting: other: '#000000' geothermal: '#ba91b1' geothermal heat: '#ba91b1' + geothermal heat preheater: '#ba91b1' geothermal district heat: '#d19D00' geothermal organic rankine cycle: '#ffbf00' AC: "#70af1d" diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 4a50c6f106..45c98deb99 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -697,6 +697,12 @@ rule build_ptes_operations: "min_bottom_temperature", ), snapshots=config_provider("snapshots"), + charge_boosting_required=config_provider( + "sector", "district_heating", "ptes", "charge_boosting_required" + ), + discharge_boosting_required=config_provider( + "sector", "district_heating", "ptes", "discharge_boosting_required" + ), input: central_heating_forward_temperature_profiles=resources( "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" @@ -727,37 +733,38 @@ rule build_ptes_operations: "../scripts/build_ptes_operations/run.py" -rule build_direct_heat_source_utilisation_profiles: +rule build_heat_source_utilisation_profiles: 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", "district_heating"), snapshots=config_provider("snapshots"), input: 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}" ) conda: "../envs/environment.yaml" script: - "../scripts/build_direct_heat_source_utilisation_profiles.py" + "../scripts/build_heat_source_utilisation_profiles.py" def solar_thermal_cutout(wildcards): @@ -1483,7 +1490,7 @@ def input_heat_source_power(w): "heat_source_power_" + heat_source_name + "_base_s_{clusters}.csv" ) for heat_source_name in config_provider( - "sector", "heat_pump_sources", "urban central" + "sector", "heat_sources", "urban central" )(w) if heat_source_name in config_provider("sector", "district_heating", "limited_heat_sources")( @@ -1512,8 +1519,7 @@ rule prepare_sector_network: emissions_scope=config_provider("energy", "emissions"), 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" @@ -1639,8 +1645,11 @@ 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" + 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" ), ates_potentials=lambda w: ( resources("ates_potentials_base_s_{clusters}_{planning_horizons}.csv") diff --git a/scripts/build_direct_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py similarity index 59% rename from scripts/build_direct_heat_source_utilisation_profiles.py rename to scripts/build_heat_source_utilisation_profiles.py index 674672292c..ca01dc2780 100644 --- a/scripts/build_direct_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -54,7 +54,7 @@ def get_source_temperature(heat_source_key: str): ) -def get_profile( +def get_direct_utilisation_profile( source_temperature: float | xr.DataArray, forward_temperature: xr.DataArray ) -> xr.DataArray | float: """ @@ -76,6 +76,39 @@ def get_profile( 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: int = 20, +) -> 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`. + return_temperature: xr.DataArray + The central heating return temperature profiles. If `xarray`, indexed by `time` and `region`. + + Returns: + ------- + xr.DataArray | float + The direct heat source utilisation profile. + + """ + return xr.where( + (source_temperature < forward_temperature) + * (source_temperature > return_temperature), + (source_temperature - return_temperature) + / (source_temperature - heat_source_cooling), + 0.0, + ) + + if __name__ == "__main__": if "snakemake" not in globals(): from scripts._helpers import mock_snakemake @@ -87,21 +120,35 @@ def get_profile( configure_logging(snakemake) set_scenario_config(snakemake) - direct_utilisation_heat_sources: list[str] = ( - snakemake.params.direct_utilisation_heat_sources - ) + heat_sources: list[str] = snakemake.params.heat_sources 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(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.direct_heat_source_utilisation_profiles) xr.concat( [ - get_profile( + get_preheater_utilisation_profile( source_temperature=get_source_temperature(heat_source_key), forward_temperature=central_heating_forward_temperature, + return_temperature=central_heating_return_temperature, + heat_source_cooling=20, # TODO: improve heat source cooling ).assign_coords(heat_source=heat_source_key) - for heat_source_key in direct_utilisation_heat_sources + for heat_source_key in heat_sources ], dim="heat_source", ).to_netcdf(snakemake.output.direct_heat_source_utilisation_profiles) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 724fb7f533..de81952d05 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -53,8 +53,8 @@ class HeatSource(Enum): """ GEOTHERMAL = "geothermal" - RIVER_WATER = "river water" - SEA_WATER = "sea water" + RIVER_WATER = "river_water" + SEA_WATER = "sea_water" AIR = "air" GROUND = "ground" PTES = "ptes" @@ -104,7 +104,6 @@ def is_limited(self) -> bool: if self in [ HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER, - HeatSource.SEA_WATER, HeatSource.PTES, ]: return True @@ -126,21 +125,6 @@ def requires_generator(self) -> bool: else: return False - @property - def requires_preheater(self) -> bool: - """ - Returns whether the heat source requires a preheater. - - Returns - ------- - bool - True if the heat source requires a preheater, False otherwise. - """ - if self in [HeatSource.GEOTHERMAL, HeatSource.PTES]: - return True - else: - return False - def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: """ Returns the capital cost for the heat source generator. @@ -165,7 +149,7 @@ def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: if self.supports_direct_utilisation: return ( costs.at[ - heat_system.heat_source_costs_name(str(self)), + heat_system.heat_source_costs_name(self), "capital_cost", ] * overdim_factor @@ -193,11 +177,13 @@ def get_lifetime(self, costs, heat_system) -> float: # For other limited sources (like river_water without direct utilisation), return np.inf # For inexhaustible sources, this method shouldn't be called if self.supports_direct_utilisation: - return costs.at[heat_system.heat_source_costs_name(str(self)), "lifetime"] + return costs.at[heat_system.heat_source_costs_name(self), "lifetime"] else: return float("inf") - def get_heat_pump_bus2(self, nodes, heat_system, heat_carrier: str) -> str: + def get_heat_pump_bus2( + self, nodes, requires_preheater: bool, heat_carrier: str + ) -> str: """ Returns the bus2 configuration for the heat pump link. @@ -205,8 +191,8 @@ def get_heat_pump_bus2(self, nodes, heat_system, heat_carrier: str) -> str: ---------- nodes : pd.Index or list The nodes for which to generate the bus name. - heat_system : HeatSystem - The heat system. + requires_preheater : bool + Whether the heat source requires a preheater. heat_carrier : str The heat carrier name. @@ -218,7 +204,7 @@ def get_heat_pump_bus2(self, nodes, heat_system, heat_carrier: str) -> str: if not self.is_limited: # Inexhaustible sources (air, ground) don't have a bus2 return "" - elif self.requires_preheater: + elif requires_preheater: # Sources with preheater use pre-chilled bus return str(nodes) + f" {heat_carrier} pre-chilled" else: @@ -249,37 +235,47 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: # This is 1 - (1/COP), representing (COP-1)/COP return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - def get_efficiency_pre_heater(self, efficiency_direct_utilisation) -> float: + def get_direct_utilisation_profile( + self, direct_utilisation_profile, nodes, n + ) -> float: """ - Returns the efficiency for the preheater link. + Returns the efficiency for direct heat utilisation. Parameters ---------- - efficiency_direct_utilisation : float or pd.Series - The efficiency of direct heat utilisation. + direct_heat_profile : xr.DataArray + DataArray containing direct heat utilisation profiles. + nodes : pd.Index or list + The nodes for which to get the efficiency. + n : pypsa.Network + The PyPSA network object (for accessing snapshots). Returns ------- float or pd.Series - The efficiency for the preheater (1 - efficiency_direct_utilisation). + The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). """ - if not self.requires_preheater: - raise ValueError( - f"Heat source {self} does not require a preheater. " - "This method should only be called for sources that support direct utilisation." + # Extract the efficiency profile from the data + # This is a binary or continuous value indicating when/how much heat + # can be directly used (1 if source temperature > forward temperature, 0 otherwise) + return ( + direct_utilisation_profile.sel( + heat_source=str(self), + name=nodes, ) - # The preheater efficiency is the complement of direct utilisation efficiency - # When direct utilisation is high (source temp > forward temp), preheater efficiency is low - # This represents the fraction of heat that goes through the heat pump vs. direct use - return 1 - efficiency_direct_utilisation + .to_pandas() + .reindex(index=n.snapshots) + ) - def get_efficiency_direct_utilisation(self, direct_heat_profile, nodes, n) -> float: + def get_preheater_utilisation_profile( + self, preheater_utilisation_profile, nodes, n + ) -> float: """ Returns the efficiency for direct heat utilisation. Parameters ---------- - direct_heat_profile : xr.DataArray + preheater_utilisation_profile : xr.DataArray DataArray containing direct heat utilisation profiles. nodes : pd.Index or list The nodes for which to get the efficiency. @@ -291,16 +287,11 @@ def get_efficiency_direct_utilisation(self, direct_heat_profile, nodes, n) -> fl float or pd.Series The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). """ - if not self.supports_direct_utilisation: - raise ValueError( - f"Heat source {self} does not support direct utilisation. " - "This method should only be called for sources in direct_utilisation_heat_sources." - ) # Extract the efficiency profile from the data # This is a binary or continuous value indicating when/how much heat # can be directly used (1 if source temperature > forward temperature, 0 otherwise) return ( - direct_heat_profile.sel( + preheater_utilisation_profile.sel( heat_source=str(self), name=nodes, ) diff --git a/scripts/definitions/heat_system.py b/scripts/definitions/heat_system.py index 3e682eea6f..886b6c03b7 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/prepare_sector_network.py b/scripts/prepare_sector_network.py index 9831b1d6c2..41d60b40ad 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2765,7 +2765,7 @@ 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, hourly_heat_demand_total_file: str, ptes_e_max_pu_file: str, ptes_direct_utilisation_profile: str, @@ -2860,7 +2860,12 @@ 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 + ) + heat_source_preheater_utilisation_profile = xr.open_dataarray( + heat_source_direct_utilisation_profile_file + ) 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"] @@ -3176,7 +3181,7 @@ 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) @@ -3185,7 +3190,7 @@ def add_heat( cop_heat_pump = ( cop.sel( heat_system=heat_system.system_type.value, - heat_source=heat_source, + heat_source=heat_source.value, name=nodes, ) .to_pandas() @@ -3194,9 +3199,9 @@ def add_heat( else costs.at[costs_name_heat_pump, "efficiency"] ) + heat_carrier = f"{heat_system} {heat_source} heat" if heat_source.is_limited: # add resource - heat_carrier = f"{heat_system} {heat_source} heat" n.add("Carrier", heat_carrier) n.add( "Bus", @@ -3206,15 +3211,12 @@ def add_heat( carrier=heat_carrier, ) - # Check if heat source requires a separate generator if heat_source.requires_generator: - # Standard heat source with potential file and generator p_max_source = pd.read_csv( - heat_source_profile_files[heat_source], + heat_source_profile_files[heat_source.value], index_col=0, ).squeeze()[nodes] - # Get capital cost and lifetime from enum capital_cost = heat_source.get_capital_cost( costs, overdim_factor, heat_system ) @@ -3232,69 +3234,59 @@ def add_heat( p_max_pu=p_max_source, ) - # Get direct utilisation efficiency if applicable - if heat_source.supports_direct_utilisation: - efficiency_direct_utilisation = ( - heat_source.get_efficiency_direct_utilisation( - direct_heat_profile, nodes, n - ) + direct_utilisation_profile = ( + heat_source.get_preheater_utilisation_profile( + heat_source_direct_utilisation_profile, nodes, n + ) + ) + preheater_utilisation_profile = ( + heat_source.get_preheater_utilisation_profile( + heat_source_preheater_utilisation_profile, nodes, n ) + ) + requires_preheater = (preheater_utilisation_profile > 0.001).any() + requires_direct_utilisation = (direct_utilisation_profile > 0.001).any() - # add heat pump converting source heat + electricity to urban central heat - if heat_source.requires_preheater: + # if any preheater value is non-zero + if requires_preheater: n.add( "Bus", nodes, location=nodes, - suffix=f" {heat_carrier}", + suffix=f" {heat_carrier} pre-chilled", carrier=f"{heat_carrier} pre-chilled", ) - efficiency_preheater = heat_source.get_efficiency_pre_heater( - efficiency_direct_utilisation - ) - n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat preheater", bus0=nodes + f" {heat_carrier}", bus1=nodes + f" {heat_system} heat", - bus2=nodes + f" {heat_system} {heat_carrier} pre-chilled", - efficiency=efficiency_preheater, + bus2=nodes + f" {heat_carrier} pre-chilled", + efficiency=preheater_utilisation_profile, + efficiency2=1 - preheater_utilisation_profile, carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, ) - # Determine bus2 and efficiency2 for heat pump - bus2_heat_pump = heat_source.get_heat_pump_bus2( - nodes, heat_system, heat_carrier - ) - efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2( - cop_heat_pump - ) - - if heat_source.supports_direct_utilisation: - # add link for direct usage of heat source when source temperature exceeds forward temperature + # add link for direct usage of heat source when source temperature exceeds forward temperature + if requires_direct_utilisation: 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, + efficiency=direct_utilisation_profile, carrier=f"{heat_system} {heat_source} heat direct utilisation", p_nom_extendable=True, ) - else: - # heat source is inexhaustible (air or ground) - bus2_heat_pump = heat_source.get_heat_pump_bus2( - nodes, heat_system, heat_carrier - ) - efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2( - cop_heat_pump - ) + bus2_heat_pump = heat_source.get_heat_pump_bus2( + nodes, requires_preheater, heat_carrier + ) + efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) n.add( "Link", @@ -6252,7 +6244,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, From f3df07d70b9a08e84be869e41bd31d93a0874b66 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 12 Nov 2025 18:42:52 +0100 Subject: [PATCH 04/65] core:update workflow --- Snakefile | 8 +- config/config.default.yaml | 24 +- config/plotting.default.yaml | 3 +- rules/build_sector.smk | 171 ++++++------ rules/postprocess.smk | 2 +- rules/solve_myopic.smk | 2 +- scripts/add_brownfield.py | 2 +- scripts/build_cop_profiles/run.py | 101 +++++-- .../build_heat_source_utilisation_profiles.py | 68 +++-- .../ptes_temperature_approximator.py | 207 +++++++++----- scripts/build_ptes_operations/run.py | 63 ++--- scripts/definitions/heat_source.py | 156 ++++++----- scripts/prepare_sector_network.py | 256 +++++++++++------- scripts/solve_network.py | 2 +- 14 files changed, 600 insertions(+), 465 deletions(-) diff --git a/Snakefile b/Snakefile index 3e0ff174fd..701e3ddf25 100644 --- a/Snakefile +++ b/Snakefile @@ -156,7 +156,7 @@ rule all: + "maps/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"], @@ -168,7 +168,7 @@ rule all: + "maps/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"], @@ -180,7 +180,7 @@ rule all: + "maps/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"], @@ -193,7 +193,7 @@ rule all: + "maps/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 6c6cfc3aee..01a16b88b3 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -520,11 +520,13 @@ sector: rolling_window_ambient_temperature: 72 relative_annual_temperature_reduction: 0.01 ptes: - dynamic_capacity: false + enable: true + temperature_dependent_capacity: false charge_boosting_required: false - discharge_boosting_required: false - max_top_temperature: 90 - min_bottom_temperature: 35 + discharge_resistive_boosting: false + top_temperature: 90 + bottom_temperature: 10 + dynamic_temperature: false ates: enable: false suitable_aquifer_types: @@ -543,19 +545,11 @@ sector: isentropic_compressor_efficiency: 0.8 heat_loss: 0.0 min_delta_t_lift: 10 #K - 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 + geothermal: + constant_temperature_celsius: 65 heat_sources: urban central: - air @@ -663,7 +657,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 d37fef4ef4..5ca7dfed80 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -457,7 +457,8 @@ plotting: services rural air heat pump: '#5af95d' urban central air heat pump: '#6cfb6b' ptes heat pump: '#5dade2' - ptes preheater: '#5dade2' + ptes heat preheater: '#5dade2' + ptes heat direct utilisation: '#5dade2' urban central ptes heat pump: '#3498db' urban central geothermal heat pump: '#4f2144' geothermal heat pump: '#4f2144' diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 45c98deb99..1e65d7e073 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -322,7 +322,6 @@ rule build_geothermal_heat_potential: constant_temperature_celsius=config_provider( "sector", "district_heating", - "limited_heat_sources", "geothermal", "constant_temperature_celsius", ), @@ -540,47 +539,34 @@ 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 - } - - # 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] - } + 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 def input_seawater_temperature(w) -> dict[str, str]: @@ -654,9 +640,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: @@ -684,24 +673,27 @@ rule build_cop_profiles: rule build_ptes_operations: 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_boosting_required=config_provider( - "sector", "district_heating", "ptes", "discharge_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" ), input: central_heating_forward_temperature_profiles=resources( @@ -712,15 +704,15 @@ 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_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: @@ -735,9 +727,19 @@ rule build_ptes_operations: rule build_heat_source_utilisation_profiles: params: - heat_sources=config_provider("sector", "heat_sources", "district_heating"), + 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", + ), input: + unpack(input_heat_source_temperature), central_heating_forward_temperature_profiles=resources( "central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc" ), @@ -1485,18 +1487,18 @@ 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_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: + result[heat_source_name] = resources( + "heat_source_power_" + heat_source_name + "_base_s_{clusters}.csv" + ) + + return result rule prepare_sector_network: @@ -1521,15 +1523,6 @@ rule prepare_sector_network: RDIR=RDIR, 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), @@ -1606,20 +1599,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) @@ -1645,12 +1654,6 @@ rule prepare_sector_network: if config_provider("sector", "enhanced_geothermal", "enable")(w) else [] ), - 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" - ), 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 a2f03f2a54..4942be194d 100644 --- a/rules/postprocess.smk +++ b/rules/postprocess.smk @@ -150,7 +150,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 5057134dc7..3e5a822bf3 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -73,7 +73,7 @@ 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"), dynamic_ptes_capacity=config_provider( "sector", "district_heating", "ptes", "dynamic_capacity" ), diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 2dd8ed9dfa..54785ce3ea 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -358,7 +358,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.ttes and snakemake.params.dynamic_ptes_capacity: update_dynamic_ptes_capacity(n, n_p, year) add_brownfield( diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index b2e49395fe..33ab52b67b 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -23,7 +23,7 @@ isentropic_compressor_efficiency: heat_loss: min_delta_t_lift: - heat_pump_sources: + heat_sources: urban central: urban decentral: rural: @@ -47,6 +47,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 @@ -106,6 +107,58 @@ def get_cop( ).cop +def get_source_temperature( + snakemake_params: dict, snakemake_input: dict, heat_source_name: str +) -> float | xr.DataArray: + 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_source_inlet_temperature( + heat_source_name: str, + source_temperature: float | xr.DataArray, + central_heating_return_temperature: xr.DataArray, +) -> float | xr.DataArray: + heat_source = HeatSource(heat_source_name) + if heat_source.requires_preheater: + # pre-heater is only used when source temperature is below return temperature, otherwise sink inlet is at return temperature and source inlet is at source temperature + return central_heating_return_temperature.where( + central_heating_return_temperature < source_temperature, source_temperature + ) + else: + return source_temperature + + +def get_sink_inlet_temperature( + heat_source_name: str, + source_temperature: float | xr.DataArray, + central_heating_return_temperature: xr.DataArray, + central_heating_forward_temperature: xr.DataArray, +) -> float | xr.DataArray: + heat_source = HeatSource(heat_source_name) + if heat_source.requires_preheater: + # pre-heater is only used when source temperature is below return temperature, otherwise sink inlet is at return temperature and source inlet is at source temperature + return central_heating_forward_temperature.where( + central_heating_return_temperature < source_temperature, + central_heating_return_temperature, + ) + else: + return central_heating_return_temperature + + if __name__ == "__main__": if "snakemake" not in globals(): from scripts._helpers import mock_snakemake @@ -125,33 +178,31 @@ 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}"] - ) + 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( + heat_source_name=heat_source_name, + source_temperature=source_temperature_celsius, + central_heating_return_temperature=central_heating_return_temperature, + ) + + sink_inlet_temperature_celsius = get_sink_inlet_temperature( + heat_source_name=heat_source_name, + source_temperature=source_temperature_celsius, + central_heating_forward_temperature=central_heating_forward_temperature, + 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, @@ -165,7 +216,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_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py index ca01dc2780..1cbfe42905 100644 --- a/scripts/build_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -19,39 +19,29 @@ 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(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. +def get_source_temperature( + snakemake_params: dict, snakemake_input: dict, heat_source_name: str +) -> float | xr.DataArray: + 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." + ) - 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()}." - ) + 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( @@ -80,7 +70,7 @@ def get_preheater_utilisation_profile( source_temperature: float | xr.DataArray, forward_temperature: xr.DataArray, return_temperature: xr.DataArray, - heat_source_cooling: int = 20, + heat_source_cooling: float, ) -> xr.DataArray | float: """ Get the direct heat source utilisation profile. @@ -103,8 +93,8 @@ def get_preheater_utilisation_profile( return xr.where( (source_temperature < forward_temperature) * (source_temperature > return_temperature), - (source_temperature - return_temperature) - / (source_temperature - heat_source_cooling), + heat_source_cooling + / (source_temperature - return_temperature + heat_source_cooling), 0.0, ) @@ -132,23 +122,31 @@ def get_preheater_utilisation_profile( xr.concat( [ get_direct_utilisation_profile( - source_temperature=get_source_temperature(heat_source_key), + 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.direct_heat_source_utilisation_profiles) + ).to_netcdf(snakemake.output.heat_source_direct_utilisation_profiles) xr.concat( [ get_preheater_utilisation_profile( - source_temperature=get_source_temperature(heat_source_key), + 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=20, # TODO: improve heat source cooling + heat_source_cooling=snakemake.params.heat_source_cooling, ).assign_coords(heat_source=heat_source_key) for heat_source_key in heat_sources ], dim="heat_source", - ).to_netcdf(snakemake.output.direct_heat_source_utilisation_profiles) + ).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 fc4e5353c7..d5bab20c76 100644 --- a/scripts/build_ptes_operations/ptes_temperature_approximator.py +++ b/scripts/build_ptes_operations/ptes_temperature_approximator.py @@ -2,30 +2,11 @@ # # SPDX-License-Identifier: MIT -from enum import Enum +import logging import xarray as xr - -class TesTemperatureMode(Enum): - """ - TES temperature profile assumptions. - - CONSTANT: Assumes fixed temperatures at operational limits. - - Top temperature: constant at max_top_temperature - - Bottom temperature: constant at min_bottom_temperature - - Assumes charge-boosting to maintain top temperature. - - NOTE: Assuming bottom_temperature = min_bottom_temperature ignores that cooling of the return temperature might be necessary in practice. - - DYNAMIC: Assumes temperatures follow network conditions. - - Top temperature: follows forward_temperature (clipped at max_top_temperature) - - Bottom temperature: follows return_temperature - - Does not assume charge-boosting. - - Note: This ignores that the TES temperatures do not match the supply temperatures due to thermal losses or other factors. - """ - - CONSTANT = "constant" - DYNAMIC = "dynamic" +logger = logging.getLogger(__name__) class PtesTemperatureApproximator: @@ -41,17 +22,15 @@ 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_top_temperature : float - Maximum operational temperature of top layer in PTES. - min_bottom_temperature : float - Minimum operational temperature of bottom layer in PTES. - temperature_profile : TesTemperatureProfile - TES temperature profile assumption. + 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. - dynamic_capacity : bool + temperature_dependent_capacity : bool Whether storage capacity varies with temperature. If False, assumes constant capacity. """ @@ -59,12 +38,11 @@ def __init__( self, forward_temperature: xr.DataArray, return_temperature: xr.DataArray, - max_top_temperature: float, - min_bottom_temperature: float, - temperature_profile: TesTemperatureMode, + top_temperature: float | str, + bottom_temperature: float | str, charge_boosting_required: bool, discharge_boosting_required: bool, - dynamic_capacity: bool, + temperature_dependent_capacity: bool, ): """ Initialize PtesTemperatureApproximator. @@ -75,67 +53,90 @@ 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_top_temperature : float - Maximum operational temperature of top layer in PTES. - min_bottom_temperature : float - Minimum operational temperature of bottom layer in PTES. - temperature_profile : TesTemperatureProfile - TES temperature profile assumption. + 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. - dynamic_capacity : bool + temperature_dependent_capacity : bool Whether storage capacity varies with temperature. If False, assumes constant capacity. """ self.forward_temperature = forward_temperature self.return_temperature = return_temperature - self.max_top_temperature = max_top_temperature - self.min_bottom_temperature = min_bottom_temperature - self.temperature_profile = temperature_profile + 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.dynamic_capacity = dynamic_capacity + self.temperature_dependent_capacity = temperature_dependent_capacity + + 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 or constant max temperature. + PTES top layer temperature profile. + + Returns either the forward temperature (if top_temperature == 'forward') + or a constant temperature profile (if top_temperature is a numeric value). Returns ------- xr.DataArray The resulting top temperature profile for PTES. """ - if self.temperature_profile == TesTemperatureMode.CONSTANT: - return xr.full_like(self.forward_temperature, self.max_top_temperature) - elif self.temperature_profile == TesTemperatureMode.DYNAMIC: - return self.forward_temperature.where( - self.forward_temperature <= self.max_top_temperature, - self.max_top_temperature, + if self.top_temperature == "forward": + logger.info( + f"PTES top temperature profile: Using dynamic forward temperature from district heating network " + f"(shape: {self.forward_temperature.shape}, range: {float(self.forward_temperature.min().values):.1f}°C to {float(self.forward_temperature.max().values):.1f}°C)" ) + return self.forward_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 NotImplementedError( - f"Temperature profile {self.temperature_profile} not implemented" + 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 or constant min 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. """ - if self.temperature_profile == TesTemperatureMode.CONSTANT: - return xr.full_like(self.return_temperature, self.min_bottom_temperature) - elif self.temperature_profile == TesTemperatureMode.DYNAMIC: + 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 NotImplementedError( - f"Temperature profile {self.temperature_profile} not implemented" + raise ValueError( + f"Invalid bottom_temperature: {self.bottom_temperature}. " + "Must be 'return' or a numeric value." ) @property @@ -149,15 +150,35 @@ def e_max_pu(self) -> xr.DataArray: xr.DataArray Normalized delta T values between 0 and 1, representing the available storage capacity as a percentage of maximum capacity. - If dynamic_capacity is False, returns constant capacity of 1.0. + If temperature_dependent_capacity is False, returns constant capacity of 1.0. """ - if self.dynamic_capacity: - delta_t = self.top_temperature - self.bottom_temperature - normalized_delta_t = delta_t / ( - self.max_top_temperature - self.min_bottom_temperature + if self.temperature_dependent_capacity: + delta_t = self.top_temperature_profile - self.bottom_temperature_profile + # Get max possible delta_t for normalization + max_top = ( + self.top_temperature + if isinstance(self.top_temperature, (int, float)) + else self.forward_temperature.max().values + ) + min_bottom = ( + self.bottom_temperature + if isinstance(self.bottom_temperature, (int, float)) + else self.return_temperature.min().values ) - return normalized_delta_t.clip(min=0) # Ensure non-negative values + max_delta_t = max_top - min_bottom + 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={max_top:.2f}°C, min_bottom={min_bottom:.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 @@ -193,11 +214,28 @@ def boost_per_discharge(self) -> xr.DataArray: The resulting fraction of PTES charge that must be further heated. """ if self.discharge_boosting_required: - return ( - (self.forward_temperature - self.top_temperature) - / (self.top_temperature - self.bottom_temperature) - ).where(self.forward_temperature > self.top_temperature, 0) + 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 @@ -234,16 +272,37 @@ def boost_per_charge(self) -> xr.DataArray: Returns ------- xr.DataArray - The fraction of the PTES's available storage capacity already used. + The ratio of additional boost energy needed to the energy already delivered by charging. """ if self.charge_boosting_required: - return ( + # Get the max top temperature value + max_top = ( + self.top_temperature + if isinstance(self.top_temperature, (int, float)) + else self.top_temperature_profile + ) + result = ( ( - (self.max_top_temperature - self.forward_temperature) + (max_top - self.forward_temperature) / (self.forward_temperature - self.return_temperature) ) - .where(self.forward_temperature < self.max_top_temperature, 0) + .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 0b26ea6108..5afacbbd63 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -19,32 +19,27 @@ sector district_heating: ptes: - dynamic_capacity: - discharge_boosting_required: + temperature_dependent_capacity: charge_boosting_required: - temperature_profile + discharge_resistive_boosting: max_top_temperature: min_bottom_temperature: Inputs ------ -- `resources//forward_temperature.nc` +- `resources//central_heating_forward_temperature_profiles.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//ptes_temperature_boost_ratio_profiles.nc` - Ratio of PTES charge that requires additional heating due to temperature differences. Outputs ------- -- `resources//ptes_top_temperature_profiles.nc` +- `resources//ptes_top_temperature_profile.nc` Clipped PTES top temperature profile (in °C). -- `resources//ptes_e_max_pu_profiles.nc` +- `resources//ptes_e_max_pu_profile.nc` Normalized PTES capacity profiles. -- `ptes_temperature_boost_ratio_profiles` (netCDF): - Charging temperature boost ratio time series. -- `ptes_forward_temperature_boost_ratio_profiles` (netCDF): - Forward flow temperature boost ratio time series. +- `resources//boost_per_discharge_profile.nc` (conditional): + Discharge temperature boost ratio time series (only if discharge_resistive_boosting is enabled). Source ------ @@ -59,7 +54,6 @@ from scripts._helpers import set_scenario_config from scripts.build_ptes_operations.ptes_temperature_approximator import ( PtesTemperatureApproximator, - TesTemperatureMode, ) logger = logging.getLogger(__name__) @@ -76,25 +70,15 @@ set_scenario_config(snakemake) - if ( - snakemake.params.charge_boosting_required - and TesTemperatureMode(snakemake.params.ptes_temperature_profile) - is TesTemperatureMode.DYNAMIC - ): - raise ValueError( - "Charger boosting cannot be used with 'dynamic' temperature profile" - ) - # Load temperature profiles logger.info( "Loading district heating temperature profiles and approximating PTES temperatures" ) - logger.info( - f"PTES configuration: temperature_profile={snakemake.params.ptes_temperature_profile}, " - f"charge_boosting_required={snakemake.params.charge_boosting_required}, " - f"discharge_boosting_required={snakemake.params.discharge_boosting_required}, " - f"dynamic_capacity={snakemake.params.dynamic_capacity}" - ) + logger.info(f"PTES configuration: {snakemake.params}") + + # Discharge boosting is "required" only if boosting via resistive heaters is enabled + # if you'd like to model boosting via heat pumps, add "ptes" to central heating heat sources and set temperatures accordingly + discharge_boosting_required: bool = snakemake.params.discharge_resistive_boosting # Initialize unified PTES temperature class ptes_temperature_approximator = PtesTemperatureApproximator( @@ -104,28 +88,21 @@ return_temperature=xr.open_dataarray( snakemake.input.central_heating_return_temperature_profiles ), - max_top_temperature=snakemake.params.max_ptes_top_temperature, - min_bottom_temperature=snakemake.params.min_ptes_bottom_temperature, - temperature_profile=TesTemperatureMode( - snakemake.params.ptes_temperature_profile - ), + top_temperature=snakemake.params.top_temperature, + bottom_temperature=snakemake.params.bottom_temperature, charge_boosting_required=snakemake.params.charge_boosting_required, - discharge_boosting_required=snakemake.params.discharge_boosting_required, - dynamic_capacity=snakemake.params.dynamic_capacity, + discharge_boosting_required=discharge_boosting_required, + temperature_dependent_capacity=snakemake.params.temperature_dependent_capacity, ) - ptes_temperature_approximator.top_temperature.to_netcdf( - snakemake.output.ptes_top_temperature_profile + ptes_temperature_approximator.top_temperature_profile.to_netcdf( + snakemake.output.ptes_top_temperature_profiles ) ptes_temperature_approximator.e_max_pu.to_netcdf( - snakemake.output.ptes_e_max_pu_profile + snakemake.output.ptes_e_max_pu_profiles ) ptes_temperature_approximator.boost_per_discharge.to_netcdf( - snakemake.output.boost_per_discharge_profile - ) - - ptes_temperature_approximator.boost_per_charge.to_netcdf( - snakemake.output.boost_per_charge_profile + snakemake.output.ptes_boost_per_discharge_profiles ) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index de81952d05..8e23d719ab 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -74,17 +74,17 @@ def __str__(self) -> str: return self.value @property - def constant_temperature_celsius(self) -> float | bool: + def has_constant_temperature(self) -> bool: """ Returns the constant temperature in Celsius for the heat source. Returns ------- - float | None - The constant temperature in Celsius, or None if not applicable. + bool + True if the heat source has a constant temperature else False. """ if self == HeatSource.GEOTHERMAL: - return 65 + return True else: return False @@ -125,6 +125,21 @@ def requires_generator(self) -> bool: else: return False + @property + def requires_preheater(self) -> bool: + """ + Returns whether the heat source requires a pre-heater. + + Returns + ------- + bool + True if the heat source requires a pre-heater, False otherwise. + """ + if self in [HeatSource.GEOTHERMAL, HeatSource.PTES]: + return True + else: + return False + def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: """ Returns the capital cost for the heat source generator. @@ -146,7 +161,7 @@ def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: # For direct utilisation heat sources, get cost from technology-data # For other limited sources (like river_water without direct utilisation), return 0.0 # For inexhaustible sources, this method shouldn't be called - if self.supports_direct_utilisation: + if self in [HeatSource.GEOTHERMAL]: return ( costs.at[ heat_system.heat_source_costs_name(self), @@ -176,14 +191,12 @@ def get_lifetime(self, costs, heat_system) -> float: # For direct utilisation heat sources, get lifetime from technology-data # For other limited sources (like river_water without direct utilisation), return np.inf # For inexhaustible sources, this method shouldn't be called - if self.supports_direct_utilisation: + if self in [HeatSource.GEOTHERMAL]: return costs.at[heat_system.heat_source_costs_name(self), "lifetime"] else: return float("inf") - def get_heat_pump_bus2( - self, nodes, requires_preheater: bool, heat_carrier: str - ) -> str: + def get_heat_pump_bus2(self, nodes, heat_carrier: str) -> str: """ Returns the bus2 configuration for the heat pump link. @@ -204,12 +217,12 @@ def get_heat_pump_bus2( if not self.is_limited: # Inexhaustible sources (air, ground) don't have a bus2 return "" - elif requires_preheater: + elif self.requires_preheater: # Sources with preheater use pre-chilled bus - return str(nodes) + f" {heat_carrier} pre-chilled" + return nodes + f" {heat_carrier} pre-chilled" else: # Limited sources without preheater use the heat carrier bus directly - return str(nodes) + f" {heat_carrier}" + return nodes + f" {heat_carrier}" def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: """ @@ -235,66 +248,59 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: # This is 1 - (1/COP), representing (COP-1)/COP return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - def get_direct_utilisation_profile( - self, direct_utilisation_profile, nodes, n - ) -> float: - """ - Returns the efficiency for direct heat utilisation. - - Parameters - ---------- - direct_heat_profile : xr.DataArray - DataArray containing direct heat utilisation profiles. - nodes : pd.Index or list - The nodes for which to get the efficiency. - n : pypsa.Network - The PyPSA network object (for accessing snapshots). - - Returns - ------- - float or pd.Series - The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). - """ - # Extract the efficiency profile from the data - # This is a binary or continuous value indicating when/how much heat - # can be directly used (1 if source temperature > forward temperature, 0 otherwise) - return ( - direct_utilisation_profile.sel( - heat_source=str(self), - name=nodes, - ) - .to_pandas() - .reindex(index=n.snapshots) - ) - - def get_preheater_utilisation_profile( - self, preheater_utilisation_profile, nodes, n - ) -> float: - """ - Returns the efficiency for direct heat utilisation. - - Parameters - ---------- - preheater_utilisation_profile : xr.DataArray - DataArray containing direct heat utilisation profiles. - nodes : pd.Index or list - The nodes for which to get the efficiency. - n : pypsa.Network - The PyPSA network object (for accessing snapshots). - - Returns - ------- - float or pd.Series - The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). - """ - # Extract the efficiency profile from the data - # This is a binary or continuous value indicating when/how much heat - # can be directly used (1 if source temperature > forward temperature, 0 otherwise) - return ( - preheater_utilisation_profile.sel( - heat_source=str(self), - name=nodes, - ) - .to_pandas() - .reindex(index=n.snapshots) - ) + # def get_preheater_utilisation_profile( + # self, preheater_utilisation_profile, nodes, n + # ) -> float: + # """ + # Returns the efficiency for direct heat utilisation. + + # Parameters + # ---------- + # preheater_utilisation_profile : xr.DataArray + # DataArray containing direct heat utilisation profiles. + # nodes : pd.Index or list + # The nodes for which to get the efficiency. + # n : pypsa.Network + # The PyPSA network object (for accessing snapshots). + + # Returns + # ------- + # float or pd.Series + # The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). + # """ + # # Extract the efficiency profile from the data + # # This is a binary or continuous value indicating when/how much heat + # # can be directly used (1 if source temperature > forward temperature, 0 otherwise) + # return ( + # preheater_utilisation_profile.sel( + # heat_source=str(self), + # name=nodes, + # ) + # .to_pandas() + # .reindex(index=n.snapshots) + # ) + + # def get_source_temperature(self, snakemake_input: dict, snakemake_params) -> float | xr.DataArray: + # """ + # Get the heat source temperature. + + # Args: + # ---- + # snakemake_input: dict + # Snakemake input dictionary containing the heat source temperature file path. + + # Returns: + # ------- + # 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 `return_temperature`. + + # """ + # if self.has_constant_temperature: + # return snakemake_params[f"{str(self)}_constant_temperature"] + # else: + # try: + # return xr.open_dataarray(snakemake_input[f"temp_{str(self)}"]) + # except KeyError: + # raise ValueError( + # f"Missing input temperature for heat source {str(self)}." + # ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 41d60b40ad..4a010799a4 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2766,9 +2766,10 @@ def add_heat( costs: pd.DataFrame, cop_profiles_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_profiles: str, ates_e_nom_max: str, ates_capex_as_fraction_of_geothermal_heat_source: float, ates_recovery_factor: float, @@ -2816,7 +2817,7 @@ def add_heat( Dictionary mapping heat source names to their data file paths params : dict Dictionary containing parameters including: - - heat_pump_sources + - heat_sources - heat_utilisation_potentials - direct_utilisation_heat_sources pop_weighted_energy_totals : pd.DataFrame @@ -2864,7 +2865,7 @@ def add_heat( heat_source_direct_utilisation_profile_file ) heat_source_preheater_utilisation_profile = xr.open_dataarray( - heat_source_direct_utilisation_profile_file + heat_source_preheater_utilisation_profile_file ) district_heat_info = pd.read_csv(district_heat_share_file, index_col=0) dist_fraction = district_heat_info["district fraction of node"] @@ -2961,7 +2962,7 @@ def add_heat( p_set=heat_load.loc[n.snapshots], ) - if options["tes"]: + if options["ttes"]: n.add("Carrier", f"{heat_system} water tanks") n.add( @@ -3038,93 +3039,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"], + ) - n.add( - "Bus", - nodes + f" {heat_system} water pits discharged", - location=nodes, - carrier=f"{heat_system} water pits discharged", - unit="MWh_th", - ) + n.add( + "Bus", + nodes + f" {heat_system} water pits discharged", + location=nodes, + carrier=f"{heat_system} water pits discharged", + 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} water pits discharged", - 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 - - 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=nodes + f" {heat_system} water pits discharged", + 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") @@ -3234,21 +3232,15 @@ def add_heat( p_max_pu=p_max_source, ) - direct_utilisation_profile = ( - heat_source.get_preheater_utilisation_profile( - heat_source_direct_utilisation_profile, nodes, n - ) - ) - preheater_utilisation_profile = ( - heat_source.get_preheater_utilisation_profile( - heat_source_preheater_utilisation_profile, nodes, n - ) - ) - requires_preheater = (preheater_utilisation_profile > 0.001).any() - requires_direct_utilisation = (direct_utilisation_profile > 0.001).any() - # if any preheater value is non-zero - if requires_preheater: + if heat_source.requires_preheater: + preheater_utilisation_profile = ( + heat_source_preheater_utilisation_profile.sel( + heat_source=heat_source.value, name=nodes + ) + .to_pandas() + .reindex(index=n.snapshots) + ) n.add( "Bus", nodes, @@ -3271,6 +3263,18 @@ def add_heat( ) # add link for direct usage of heat source when source temperature exceeds forward temperature + direct_utilisation_profile = ( + heat_source_direct_utilisation_profile.sel( + heat_source=heat_source.value, name=nodes + ) + .to_pandas() + .reindex(index=n.snapshots) + ) + + requires_direct_utilisation = ( + (direct_utilisation_profile > 0.001).any().any() + ) + if requires_direct_utilisation: n.add( "Link", @@ -3283,9 +3287,7 @@ def add_heat( p_nom_extendable=True, ) - bus2_heat_pump = heat_source.get_heat_pump_bus2( - nodes, requires_preheater, heat_carrier - ) + bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_carrier) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) n.add( @@ -3309,11 +3311,55 @@ def add_heat( if options["resistive_heaters"]: key = f"{heat_system.central_or_decentral} resistive heater" + if ( + heat_system == HeatSystem.URBAN_CENTRAL + and params.sector["district_heating"]["ptes"][ + "discharge_resistive_boosting" + ] + ): + n.add( + "Bus", + nodes, + location=nodes, + suffix=" resistive heater", + carrier=f"{heat_system} resistive heater", + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} water pits resistive booster", + bus0=nodes + f" {heat_system} heat", + bus1=nodes + f" {heat_system} resistive heater", + bus2=nodes + f" {heat_system} water pits discharged", + carrier=f"{heat_system} water pits resistive booster", + efficiency=1 / ptes_boost_per_discharge_profiles, + efficiency2=1 - 1 / ptes_boost_per_discharge_profiles, + p_nom_extendable=True, + p_min_pu=-1, + p_max_pu=0, + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} resistive heater stand-alone", + bus0=nodes + f" {heat_system} resistive heater", + bus1=nodes + f" {heat_system} heat", + carrier=f"{heat_system} resistive heater stand-alone", + efficiency=1.0, + p_nom_extendable=True, + ) + + resistive_heater_bus1 = nodes + f" {heat_system} resistive heater" + 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"] @@ -6259,15 +6305,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_profiles=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 }, params=snakemake.params, pop_weighted_energy_totals=pop_weighted_energy_totals, diff --git a/scripts/solve_network.py b/scripts/solve_network.py index 9259d233c9..80d407fd81 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1210,7 +1210,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, From 4a00e3c3729b8103c46a04bb380ca9b5ea63ad97 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 19 Nov 2025 14:06:12 +0100 Subject: [PATCH 05/65] introduce ptes design temperatures --- config/config.default.yaml | 3 ++- rules/build_sector.smk | 12 ++++++++++++ .../ptes_temperature_approximator.py | 18 ++++++------------ scripts/build_ptes_operations/run.py | 2 ++ 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 01a16b88b3..1efab5947d 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -526,7 +526,8 @@ sector: discharge_resistive_boosting: false top_temperature: 90 bottom_temperature: 10 - dynamic_temperature: false + design_top_temperature: 90 + design_bottom_temperature: 35 ates: enable: false suitable_aquifer_types: diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 1e65d7e073..db83790c53 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -695,6 +695,18 @@ rule build_ptes_operations: 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" diff --git a/scripts/build_ptes_operations/ptes_temperature_approximator.py b/scripts/build_ptes_operations/ptes_temperature_approximator.py index d5bab20c76..6445ae72f4 100644 --- a/scripts/build_ptes_operations/ptes_temperature_approximator.py +++ b/scripts/build_ptes_operations/ptes_temperature_approximator.py @@ -43,6 +43,8 @@ def __init__( charge_boosting_required: bool, discharge_boosting_required: bool, temperature_dependent_capacity: bool, + design_top_temperature: float, + design_bottom_temperature: float, ): """ Initialize PtesTemperatureApproximator. @@ -71,6 +73,8 @@ def __init__( 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( @@ -155,22 +159,12 @@ def e_max_pu(self) -> xr.DataArray: if self.temperature_dependent_capacity: delta_t = self.top_temperature_profile - self.bottom_temperature_profile # Get max possible delta_t for normalization - max_top = ( - self.top_temperature - if isinstance(self.top_temperature, (int, float)) - else self.forward_temperature.max().values - ) - min_bottom = ( - self.bottom_temperature - if isinstance(self.bottom_temperature, (int, float)) - else self.return_temperature.min().values - ) - max_delta_t = max_top - min_bottom + 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={max_top:.2f}°C, min_bottom={min_bottom:.2f}°C). " + 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 diff --git a/scripts/build_ptes_operations/run.py b/scripts/build_ptes_operations/run.py index 5afacbbd63..e67da20ce6 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -93,6 +93,8 @@ 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, ) ptes_temperature_approximator.top_temperature_profile.to_netcdf( From 762db1e04ed27312b54b9b166a73b78d0e1ee5b1 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 19 Nov 2025 14:06:56 +0100 Subject: [PATCH 06/65] fix boosting bus naming --- scripts/definitions/heat_source.py | 10 +- scripts/prepare_sector_network.py | 156 +++++++++++++++-------------- 2 files changed, 88 insertions(+), 78 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 8e23d719ab..25e8252ddc 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -89,7 +89,7 @@ def has_constant_temperature(self) -> bool: return False @property - def is_limited(self) -> bool: + def requires_bus(self) -> bool: """ Returns whether the heat source is limited (vs. inexhaustible). @@ -104,7 +104,6 @@ def is_limited(self) -> bool: if self in [ HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER, - HeatSource.PTES, ]: return True else: @@ -214,8 +213,8 @@ def get_heat_pump_bus2(self, nodes, heat_carrier: str) -> str: str The bus2 name for the heat pump, or empty string if not applicable. """ - if not self.is_limited: - # Inexhaustible sources (air, ground) don't have a bus2 + if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: + # Inexhaustible sources (air, ground, sea-water) don't have a bus2 return "" elif self.requires_preheater: # Sources with preheater use pre-chilled bus @@ -238,7 +237,8 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: float or pd.Series The efficiency2 value for the heat pump. """ - if not self.is_limited: + if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: + # Inexhaustible sources (air, ground, sea-water) don't have a bus2 # Inexhaustible sources (air, ground, sea_water) have efficiency2 = 1 # (no resource consumption from dummy bus) return 1.0 diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 4a010799a4..b99cb4c39a 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2769,7 +2769,7 @@ def add_heat( heat_source_preheater_utilisation_profile_file: str, hourly_heat_demand_total_file: str, ptes_e_max_pu_file: str, - ptes_boost_per_discharge_profiles: 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, @@ -3075,9 +3075,9 @@ def add_heat( n.add( "Bus", - nodes + f" {heat_system} water pits discharged", + nodes + f" {heat_system} ptes heat", location=nodes, - carrier=f"{heat_system} water pits discharged", + carrier=f"{heat_system} ptes heat", unit="MWh_th", ) @@ -3086,7 +3086,7 @@ def add_heat( nodes, suffix=f" {heat_system} water pits discharger", bus0=nodes + f" {heat_system} water pits", - bus1=nodes + f" {heat_system} water pits discharged", + bus1=nodes + f" {heat_system} ptes heat", carrier=f"{heat_system} water pits discharger", efficiency=costs.at[ "central water pit discharger", @@ -3198,7 +3198,7 @@ def add_heat( ) heat_carrier = f"{heat_system} {heat_source} heat" - if heat_source.is_limited: + if heat_source.requires_bus: # add resource n.add("Carrier", heat_carrier) n.add( @@ -3209,60 +3209,61 @@ def add_heat( carrier=heat_carrier, ) - if heat_source.requires_generator: - p_max_source = pd.read_csv( - heat_source_profile_files[heat_source.value], - index_col=0, - ).squeeze()[nodes] + if heat_source.requires_generator: + p_max_source = pd.read_csv( + heat_source_profile_files[heat_source.value], + index_col=0, + ).squeeze()[nodes] - 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}", - carrier=heat_carrier, - p_nom_extendable=True, - capital_cost=capital_cost, - lifetime=lifetime, - p_max_pu=p_max_source, - ) + capital_cost = heat_source.get_capital_cost( + costs, overdim_factor, heat_system + ) + lifetime = heat_source.get_lifetime(costs, heat_system) - # if any preheater value is non-zero - if heat_source.requires_preheater: - preheater_utilisation_profile = ( - heat_source_preheater_utilisation_profile.sel( - heat_source=heat_source.value, name=nodes - ) - .to_pandas() - .reindex(index=n.snapshots) - ) - n.add( - "Bus", - nodes, - location=nodes, - suffix=f" {heat_carrier} pre-chilled", - carrier=f"{heat_carrier} pre-chilled", - ) + n.add( + "Generator", + nodes, + suffix=f" {heat_carrier}", + bus=nodes + f" {heat_carrier}", + carrier=heat_carrier, + p_nom_extendable=True, + capital_cost=capital_cost, + lifetime=lifetime, + p_max_pu=p_max_source, + ) - n.add( - "Link", - nodes, - suffix=f" {heat_system} {heat_source} heat preheater", - bus0=nodes + f" {heat_carrier}", - bus1=nodes + f" {heat_system} heat", - bus2=nodes + f" {heat_carrier} pre-chilled", - efficiency=preheater_utilisation_profile, - efficiency2=1 - preheater_utilisation_profile, - carrier=f"{heat_system} {heat_source} heat preheater", - p_nom_extendable=True, + # if any preheater value is non-zero + if heat_source.requires_preheater: + preheater_utilisation_profile = ( + heat_source_preheater_utilisation_profile.sel( + heat_source=heat_source.value, name=nodes ) + .to_pandas() + .reindex(index=n.snapshots) + ) + n.add( + "Bus", + nodes, + location=nodes, + suffix=f" {heat_carrier} pre-chilled", + carrier=f"{heat_carrier} pre-chilled", + ) + + n.add( + "Link", + nodes, + suffix=f" {heat_system} {heat_source} heat preheater", + bus0=nodes + f" {heat_carrier}", + bus1=nodes + f" {heat_system} heat", + bus2=nodes + f" {heat_carrier} pre-chilled", + efficiency=preheater_utilisation_profile, + efficiency2=1 - preheater_utilisation_profile, + carrier=f"{heat_system} {heat_source} heat preheater", + p_nom_extendable=True, + ) - # add link for direct usage of heat source when source temperature exceeds forward temperature + # add link for direct usage of heat source when source temperature exceeds forward temperature + if heat_source.value in heat_source_direct_utilisation_profile.heat_source: direct_utilisation_profile = ( heat_source_direct_utilisation_profile.sel( heat_source=heat_source.value, name=nodes @@ -3274,18 +3275,20 @@ def add_heat( requires_direct_utilisation = ( (direct_utilisation_profile > 0.001).any().any() ) + else: + requires_direct_utilisation = False - if requires_direct_utilisation: - 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=direct_utilisation_profile, - carrier=f"{heat_system} {heat_source} heat direct utilisation", - p_nom_extendable=True, - ) + if requires_direct_utilisation: + 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=direct_utilisation_profile, + carrier=f"{heat_system} {heat_source} heat direct utilisation", + p_nom_extendable=True, + ) bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_carrier) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) @@ -3317,12 +3320,19 @@ def add_heat( "discharge_resistive_boosting" ] ): + 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=" resistive heater", - carrier=f"{heat_system} resistive heater", + suffix=f" {heat_system} resistive heat", + carrier=f"{heat_system} resistive heat", ) n.add( @@ -3330,8 +3340,8 @@ def add_heat( nodes, suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", - bus1=nodes + f" {heat_system} resistive heater", - bus2=nodes + f" {heat_system} water pits discharged", + bus1=nodes + f" {heat_system} resistive heat", + bus2=nodes + f" {heat_system} ptes heat", carrier=f"{heat_system} water pits resistive booster", efficiency=1 / ptes_boost_per_discharge_profiles, efficiency2=1 - 1 / ptes_boost_per_discharge_profiles, @@ -3344,14 +3354,14 @@ def add_heat( "Link", nodes, suffix=f" {heat_system} resistive heater stand-alone", - bus0=nodes + f" {heat_system} resistive heater", + bus0=nodes + f" {heat_system} resistive heat", bus1=nodes + f" {heat_system} heat", - carrier=f"{heat_system} resistive heater stand-alone", + carrier=f"{heat_system} resistive heat stand-alone", efficiency=1.0, p_nom_extendable=True, ) - resistive_heater_bus1 = nodes + f" {heat_system} resistive heater" + resistive_heater_bus1 = nodes + f" {heat_system} resistive heat" else: resistive_heater_bus1 = nodes + f" {heat_system} heat" @@ -6305,7 +6315,7 @@ def add_import_options( "recovery_factor" ], enable_ates=snakemake.params.sector["district_heating"]["ates"]["enable"], - ptes_boost_per_discharge_profiles=snakemake.input.ptes_boost_per_discharge_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, From cd7a63ea5573ed49c0a7932de662ab71175295a4 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 19 Nov 2025 18:44:05 +0100 Subject: [PATCH 07/65] fix resistive booster bus --- scripts/prepare_sector_network.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b99cb4c39a..6edfec6179 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3122,6 +3122,7 @@ def add_heat( / 100, # convert %/hour into unit/hour capital_cost=costs.at["central water pit storage", "capital_cost"], lifetime=costs.at["central water pit storage", "lifetime"], + e_nom_min=100000, ) if enable_ates and heat_system == HeatSystem.URBAN_CENTRAL: @@ -3343,8 +3344,9 @@ def add_heat( bus1=nodes + f" {heat_system} resistive heat", bus2=nodes + f" {heat_system} ptes heat", carrier=f"{heat_system} water pits resistive booster", - efficiency=1 / ptes_boost_per_discharge_profiles, - efficiency2=1 - 1 / ptes_boost_per_discharge_profiles, + efficiency=ptes_boost_per_discharge_profiles + / (ptes_boost_per_discharge_profiles + 1), + efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), p_nom_extendable=True, p_min_pu=-1, p_max_pu=0, From 012448fd38e6ee4260ee91703b0ff325d5a6b4e4 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 19 Nov 2025 18:44:14 +0100 Subject: [PATCH 08/65] nicer colours --- config/plotting.default.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index 5ca7dfed80..eaf3de9c75 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -457,9 +457,8 @@ plotting: services rural air heat pump: '#5af95d' urban central air heat pump: '#6cfb6b' ptes heat pump: '#5dade2' - ptes heat preheater: '#5dade2' - ptes heat direct utilisation: '#5dade2' - urban central ptes heat pump: '#3498db' + ptes heat preheater: '#c3d9e8' + ptes heat direct utilisation: '#2013d1' urban central geothermal heat pump: '#4f2144' geothermal heat pump: '#4f2144' geothermal heat direct utilisation: '#ba91b1' @@ -480,6 +479,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' @@ -571,6 +572,7 @@ plotting: geothermal heat preheater: '#ba91b1' geothermal district heat: '#d19D00' geothermal organic rankine cycle: '#ffbf00' + urban central geothermal heat pre-chilled: '#ba91b1' AC: "#70af1d" AC-AC: "#70af1d" AC line: "#70af1d" From fb4dcfa80c007d8d1123714ffa0a0fea2afdcf5a Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 19 Nov 2025 19:17:42 +0100 Subject: [PATCH 09/65] disallow boosting when boosting_profile=0 --- scripts/prepare_sector_network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6edfec6179..40370221d1 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3348,7 +3348,8 @@ def add_heat( / (ptes_boost_per_discharge_profiles + 1), efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), p_nom_extendable=True, - p_min_pu=-1, + p_min_pu=-ptes_boost_per_discharge_profiles + / ptes_boost_per_discharge_profiles.clip(lower=0.001), p_max_pu=0, ) From ba2658804900c5418a37699847bb27989eb10d8d Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 20 Nov 2025 14:57:22 +0100 Subject: [PATCH 10/65] disable PTES heat pumps when discharge_resistive_boosting is True --- scripts/definitions/heat_source.py | 77 ++++++++---------------------- scripts/prepare_sector_network.py | 31 +++++++----- 2 files changed, 39 insertions(+), 69 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 25e8252ddc..fe5c357c20 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -2,8 +2,11 @@ # # SPDX-License-Identifier: MIT +import logging from enum import Enum +logger = logging.getLogger(__name__) + class HeatSource(Enum): """ @@ -248,59 +251,21 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: # This is 1 - (1/COP), representing (COP-1)/COP return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - # def get_preheater_utilisation_profile( - # self, preheater_utilisation_profile, nodes, n - # ) -> float: - # """ - # Returns the efficiency for direct heat utilisation. - - # Parameters - # ---------- - # preheater_utilisation_profile : xr.DataArray - # DataArray containing direct heat utilisation profiles. - # nodes : pd.Index or list - # The nodes for which to get the efficiency. - # n : pypsa.Network - # The PyPSA network object (for accessing snapshots). - - # Returns - # ------- - # float or pd.Series - # The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). - # """ - # # Extract the efficiency profile from the data - # # This is a binary or continuous value indicating when/how much heat - # # can be directly used (1 if source temperature > forward temperature, 0 otherwise) - # return ( - # preheater_utilisation_profile.sel( - # heat_source=str(self), - # name=nodes, - # ) - # .to_pandas() - # .reindex(index=n.snapshots) - # ) - - # def get_source_temperature(self, snakemake_input: dict, snakemake_params) -> float | xr.DataArray: - # """ - # Get the heat source temperature. - - # Args: - # ---- - # snakemake_input: dict - # Snakemake input dictionary containing the heat source temperature file path. - - # Returns: - # ------- - # 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 `return_temperature`. - - # """ - # if self.has_constant_temperature: - # return snakemake_params[f"{str(self)}_constant_temperature"] - # else: - # try: - # return xr.open_dataarray(snakemake_input[f"temp_{str(self)}"]) - # except KeyError: - # raise ValueError( - # f"Missing input temperature for heat source {str(self)}." - # ) + def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: + """ + Returns whether the heat source requires a heat pump. + + Returns + ------- + bool + True if the heat source requires a heat pump, False 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 diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 40370221d1..4cc5d62259 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3368,19 +3368,24 @@ def add_heat( else: resistive_heater_bus1 = nodes + f" {heat_system} heat" - n.add( - "Link", - nodes + f" {heat_system} resistive heater", - bus0=nodes, - bus1=resistive_heater_bus1, - carrier=f"{heat_system} resistive heater", - efficiency=costs.at[key, "efficiency"], - capital_cost=costs.at[key, "efficiency"] - * costs.at[key, "capital_cost"] - * overdim_factor, - p_nom_extendable=True, - lifetime=costs.at[key, "lifetime"], - ) + if heat_source.requires_heat_pump( + ptes_discharge_resistive_boosting=params.sector["district_heating"][ + "ptes" + ]["discharge_resistive_boosting"] + ): + n.add( + "Link", + nodes + f" {heat_system} resistive heater", + bus0=nodes, + bus1=resistive_heater_bus1, + carrier=f"{heat_system} resistive heater", + efficiency=costs.at[key, "efficiency"], + capital_cost=costs.at[key, "efficiency"] + * costs.at[key, "capital_cost"] + * overdim_factor, + p_nom_extendable=True, + lifetime=costs.at[key, "lifetime"], + ) if options["boilers"]: key = f"{heat_system.central_or_decentral} gas boiler" From 34f005fb2e74f8a29fda04affd836928d945078f Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 20 Nov 2025 15:47:52 +0100 Subject: [PATCH 11/65] disallow preheating when preheater_utilisation=0 --- scripts/prepare_sector_network.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 4cc5d62259..7ef9532974 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3259,6 +3259,8 @@ def add_heat( bus2=nodes + f" {heat_carrier} pre-chilled", efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, + p_max_pu=preheater_utilisation_profile + / preheater_utilisation_profile.clip(lower=0.001), carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, ) From b48ccf3a8b253d9f56adfccd2810e82c37b5db6e Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 20 Nov 2025 15:48:07 +0100 Subject: [PATCH 12/65] Revert "disable PTES heat pumps when discharge_resistive_boosting is True" This reverts commit ba2658804900c5418a37699847bb27989eb10d8d. --- scripts/definitions/heat_source.py | 77 ++++++++++++++++++++++-------- scripts/prepare_sector_network.py | 31 +++++------- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index fe5c357c20..25e8252ddc 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -2,11 +2,8 @@ # # SPDX-License-Identifier: MIT -import logging from enum import Enum -logger = logging.getLogger(__name__) - class HeatSource(Enum): """ @@ -251,21 +248,59 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: # This is 1 - (1/COP), representing (COP-1)/COP return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: - """ - Returns whether the heat source requires a heat pump. - - Returns - ------- - bool - True if the heat source requires a heat pump, False 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_preheater_utilisation_profile( + # self, preheater_utilisation_profile, nodes, n + # ) -> float: + # """ + # Returns the efficiency for direct heat utilisation. + + # Parameters + # ---------- + # preheater_utilisation_profile : xr.DataArray + # DataArray containing direct heat utilisation profiles. + # nodes : pd.Index or list + # The nodes for which to get the efficiency. + # n : pypsa.Network + # The PyPSA network object (for accessing snapshots). + + # Returns + # ------- + # float or pd.Series + # The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). + # """ + # # Extract the efficiency profile from the data + # # This is a binary or continuous value indicating when/how much heat + # # can be directly used (1 if source temperature > forward temperature, 0 otherwise) + # return ( + # preheater_utilisation_profile.sel( + # heat_source=str(self), + # name=nodes, + # ) + # .to_pandas() + # .reindex(index=n.snapshots) + # ) + + # def get_source_temperature(self, snakemake_input: dict, snakemake_params) -> float | xr.DataArray: + # """ + # Get the heat source temperature. + + # Args: + # ---- + # snakemake_input: dict + # Snakemake input dictionary containing the heat source temperature file path. + + # Returns: + # ------- + # 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 `return_temperature`. + + # """ + # if self.has_constant_temperature: + # return snakemake_params[f"{str(self)}_constant_temperature"] + # else: + # try: + # return xr.open_dataarray(snakemake_input[f"temp_{str(self)}"]) + # except KeyError: + # raise ValueError( + # f"Missing input temperature for heat source {str(self)}." + # ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 7ef9532974..852c86d97c 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3370,24 +3370,19 @@ def add_heat( else: resistive_heater_bus1 = nodes + f" {heat_system} heat" - if heat_source.requires_heat_pump( - ptes_discharge_resistive_boosting=params.sector["district_heating"][ - "ptes" - ]["discharge_resistive_boosting"] - ): - n.add( - "Link", - nodes + f" {heat_system} resistive heater", - bus0=nodes, - bus1=resistive_heater_bus1, - carrier=f"{heat_system} resistive heater", - efficiency=costs.at[key, "efficiency"], - capital_cost=costs.at[key, "efficiency"] - * costs.at[key, "capital_cost"] - * overdim_factor, - p_nom_extendable=True, - lifetime=costs.at[key, "lifetime"], - ) + n.add( + "Link", + nodes + f" {heat_system} resistive heater", + bus0=nodes, + bus1=resistive_heater_bus1, + carrier=f"{heat_system} resistive heater", + efficiency=costs.at[key, "efficiency"], + capital_cost=costs.at[key, "efficiency"] + * costs.at[key, "capital_cost"] + * overdim_factor, + p_nom_extendable=True, + lifetime=costs.at[key, "lifetime"], + ) if options["boilers"]: key = f"{heat_system.central_or_decentral} gas boiler" From 22bd815d46687741b6623e23deb7e1ba28c6e358 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 20 Nov 2025 15:51:50 +0100 Subject: [PATCH 13/65] fix PTES heat pump condition --- scripts/definitions/heat_source.py | 77 ++++++++---------------------- scripts/prepare_sector_network.py | 39 ++++++++------- 2 files changed, 43 insertions(+), 73 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 25e8252ddc..fe5c357c20 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -2,8 +2,11 @@ # # SPDX-License-Identifier: MIT +import logging from enum import Enum +logger = logging.getLogger(__name__) + class HeatSource(Enum): """ @@ -248,59 +251,21 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: # This is 1 - (1/COP), representing (COP-1)/COP return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - # def get_preheater_utilisation_profile( - # self, preheater_utilisation_profile, nodes, n - # ) -> float: - # """ - # Returns the efficiency for direct heat utilisation. - - # Parameters - # ---------- - # preheater_utilisation_profile : xr.DataArray - # DataArray containing direct heat utilisation profiles. - # nodes : pd.Index or list - # The nodes for which to get the efficiency. - # n : pypsa.Network - # The PyPSA network object (for accessing snapshots). - - # Returns - # ------- - # float or pd.Series - # The efficiency for direct utilisation (1 if source temp exceeds forward temp, 0 otherwise). - # """ - # # Extract the efficiency profile from the data - # # This is a binary or continuous value indicating when/how much heat - # # can be directly used (1 if source temperature > forward temperature, 0 otherwise) - # return ( - # preheater_utilisation_profile.sel( - # heat_source=str(self), - # name=nodes, - # ) - # .to_pandas() - # .reindex(index=n.snapshots) - # ) - - # def get_source_temperature(self, snakemake_input: dict, snakemake_params) -> float | xr.DataArray: - # """ - # Get the heat source temperature. - - # Args: - # ---- - # snakemake_input: dict - # Snakemake input dictionary containing the heat source temperature file path. - - # Returns: - # ------- - # 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 `return_temperature`. - - # """ - # if self.has_constant_temperature: - # return snakemake_params[f"{str(self)}_constant_temperature"] - # else: - # try: - # return xr.open_dataarray(snakemake_input[f"temp_{str(self)}"]) - # except KeyError: - # raise ValueError( - # f"Missing input temperature for heat source {str(self)}." - # ) + def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: + """ + Returns whether the heat source requires a heat pump. + + Returns + ------- + bool + True if the heat source requires a heat pump, False 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 diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 852c86d97c..6605721948 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3296,23 +3296,28 @@ def add_heat( bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_carrier) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) - n.add( - "Link", - nodes, - suffix=f" {heat_system} {heat_source} heat pump", - bus0=nodes + f" {heat_system} heat", - bus1=nodes, - bus2=bus2_heat_pump, - carrier=f"{heat_system} {heat_source} heat pump", - efficiency=1 / cop_heat_pump.clip(lower=0.001), - efficiency2=efficiency2_heat_pump, - 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), - p_max_pu=0, - p_nom_extendable=True, - lifetime=costs.at[costs_name_heat_pump, "lifetime"], - ) + 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=bus2_heat_pump, + carrier=f"{heat_system} {heat_source} heat pump", + efficiency=1 / cop_heat_pump.clip(lower=0.001), + efficiency2=efficiency2_heat_pump, + 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), + p_max_pu=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" From 735748df878532a7be13ece2a2705d07847316b4 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 20 Nov 2025 20:06:37 +0100 Subject: [PATCH 14/65] hot-fix bus structure for boosting --- scripts/prepare_sector_network.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6605721948..c8b3bc57a6 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3254,7 +3254,7 @@ def add_heat( "Link", nodes, suffix=f" {heat_system} {heat_source} heat preheater", - bus0=nodes + f" {heat_carrier}", + bus0=nodes + f" {heat_carrier} to be boosted", bus1=nodes + f" {heat_system} heat", bus2=nodes + f" {heat_carrier} pre-chilled", efficiency=preheater_utilisation_profile, @@ -3263,6 +3263,7 @@ def add_heat( / preheater_utilisation_profile.clip(lower=0.001), carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, + marginal_cost=-0.3, ) # add link for direct usage of heat source when source temperature exceeds forward temperature @@ -3282,15 +3283,27 @@ def add_heat( requires_direct_utilisation = False if requires_direct_utilisation: + # if True: + n.add( + "Bus", + nodes, + location=nodes, + suffix=f" {heat_carrier} to be boosted", + carrier=f"{heat_carrier} to be boosted", + ) + n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat direct utilisation", bus0=nodes + f" {heat_carrier}", bus1=nodes + f" {heat_system} heat", + bus2=nodes + f" {heat_carrier} to be boosted", efficiency=direct_utilisation_profile, + efficiency2=1 - direct_utilisation_profile, carrier=f"{heat_system} {heat_source} heat direct utilisation", p_nom_extendable=True, + marginal_cost=0.1, ) bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_carrier) @@ -3349,15 +3362,16 @@ def add_heat( suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} resistive heat", - bus2=nodes + f" {heat_system} ptes heat", - carrier=f"{heat_system} water pits resistive booster", + bus2=nodes + f" {heat_system} ptes heat to be boosted", efficiency=ptes_boost_per_discharge_profiles / (ptes_boost_per_discharge_profiles + 1), efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), p_nom_extendable=True, p_min_pu=-ptes_boost_per_discharge_profiles / ptes_boost_per_discharge_profiles.clip(lower=0.001), + carrier=f"{heat_system} water pits resistive booster", p_max_pu=0, + marginal_cost=-0.3, ) n.add( From a326ca72420ba95da3385b73f715c76ee264d888 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 24 Nov 2025 10:48:51 +0100 Subject: [PATCH 15/65] rename intermediate boosting buses --- scripts/definitions/heat_source.py | 4 +- scripts/prepare_sector_network.py | 74 ++++++++++++++---------------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index fe5c357c20..455085db03 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -220,8 +220,8 @@ def get_heat_pump_bus2(self, nodes, heat_carrier: str) -> str: # Inexhaustible sources (air, ground, sea-water) don't have a bus2 return "" elif self.requires_preheater: - # Sources with preheater use pre-chilled bus - return nodes + f" {heat_carrier} pre-chilled" + # Sources with preheater use return-temperature bus + return nodes + f" {heat_carrier} return-temperature" else: # Limited sources without preheater use the heat carrier bus directly return nodes + f" {heat_carrier}" diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index c8b3bc57a6..fae2a9224f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3199,16 +3199,6 @@ def add_heat( ) heat_carrier = f"{heat_system} {heat_source} heat" - if heat_source.requires_bus: - # add resource - n.add("Carrier", heat_carrier) - n.add( - "Bus", - nodes, - location=nodes, - suffix=f" {heat_carrier}", - carrier=heat_carrier, - ) if heat_source.requires_generator: p_max_source = pd.read_csv( @@ -3233,8 +3223,23 @@ def add_heat( p_max_pu=p_max_source, ) - # if any preheater value is non-zero - if heat_source.requires_preheater: + if heat_source.requires_bus: + medium_temperature_carrier = f"{heat_carrier} medium-temperature" + return_temperature_carrier = f"{heat_carrier} return-temperature" + medium_temperature_bus = nodes + f" {medium_temperature_carrier}" + return_temperature_bus = nodes + f" {return_temperature_carrier}" + print(medium_temperature_bus, return_temperature_bus) + + # add resource + n.add("Carrier", heat_carrier) + n.add( + "Bus", + nodes, + location=nodes, + suffix=f" {heat_carrier}", + carrier=heat_carrier, + ) + preheater_utilisation_profile = ( heat_source_preheater_utilisation_profile.sel( heat_source=heat_source.value, name=nodes @@ -3242,21 +3247,29 @@ def add_heat( .to_pandas() .reindex(index=n.snapshots) ) + n.add( "Bus", - nodes, + medium_temperature_bus, + location=nodes, + carrier=medium_temperature_carrier, + ) + + n.add( + "Bus", + return_temperature_bus, + suffix=return_temperature_carrier, location=nodes, - suffix=f" {heat_carrier} pre-chilled", - carrier=f"{heat_carrier} pre-chilled", + carrier=return_temperature_carrier, ) n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat preheater", - bus0=nodes + f" {heat_carrier} to be boosted", + bus0=medium_temperature_bus, bus1=nodes + f" {heat_system} heat", - bus2=nodes + f" {heat_carrier} pre-chilled", + bus2=return_temperature_bus, efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, p_max_pu=preheater_utilisation_profile @@ -3266,8 +3279,6 @@ def add_heat( marginal_cost=-0.3, ) - # add link for direct usage of heat source when source temperature exceeds forward temperature - if heat_source.value in heat_source_direct_utilisation_profile.heat_source: direct_utilisation_profile = ( heat_source_direct_utilisation_profile.sel( heat_source=heat_source.value, name=nodes @@ -3276,32 +3287,18 @@ def add_heat( .reindex(index=n.snapshots) ) - requires_direct_utilisation = ( - (direct_utilisation_profile > 0.001).any().any() - ) - else: - requires_direct_utilisation = False - - if requires_direct_utilisation: - # if True: - n.add( - "Bus", - nodes, - location=nodes, - suffix=f" {heat_carrier} to be boosted", - carrier=f"{heat_carrier} to be boosted", - ) + # 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", + suffix=f" {heat_system} {heat_source} heat utilisation", bus0=nodes + f" {heat_carrier}", bus1=nodes + f" {heat_system} heat", - bus2=nodes + f" {heat_carrier} to be boosted", + bus2=medium_temperature_bus, efficiency=direct_utilisation_profile, efficiency2=1 - direct_utilisation_profile, - carrier=f"{heat_system} {heat_source} heat direct utilisation", + carrier=f"{heat_system} {heat_source} heat utilisation", p_nom_extendable=True, marginal_cost=0.1, ) @@ -3362,7 +3359,7 @@ def add_heat( suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} resistive heat", - bus2=nodes + f" {heat_system} ptes heat to be boosted", + bus2=nodes + f" {heat_system} ptes heat medium-temperature", efficiency=ptes_boost_per_discharge_profiles / (ptes_boost_per_discharge_profiles + 1), efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), @@ -3371,7 +3368,6 @@ def add_heat( / ptes_boost_per_discharge_profiles.clip(lower=0.001), carrier=f"{heat_system} water pits resistive booster", p_max_pu=0, - marginal_cost=-0.3, ) n.add( From 2cbbb88ac99d67bd872379c362a57e42abab7382 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 24 Nov 2025 17:10:29 +0100 Subject: [PATCH 16/65] Move bus/carrier naming to heat_source class and update colours --- config/plotting.default.yaml | 4 +- scripts/definitions/heat_source.py | 79 ++++++++++++++++++++++++++++-- scripts/prepare_sector_network.py | 41 ++++++---------- 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index eaf3de9c75..e3aaed13ee 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -458,10 +458,10 @@ plotting: urban central air heat pump: '#6cfb6b' ptes heat pump: '#5dade2' ptes heat preheater: '#c3d9e8' - ptes heat direct utilisation: '#2013d1' + 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 pump: '#4bb9f2' sea_water heat: '#0b222e' diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 455085db03..c376f6e699 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -107,6 +107,7 @@ def requires_bus(self) -> bool: if self in [ HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER, + HeatSource.PTES, ]: return True else: @@ -198,7 +199,7 @@ def get_lifetime(self, costs, heat_system) -> float: else: return float("inf") - def get_heat_pump_bus2(self, nodes, heat_carrier: str) -> str: + def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: """ Returns the bus2 configuration for the heat pump link. @@ -221,10 +222,10 @@ def get_heat_pump_bus2(self, nodes, heat_carrier: str) -> str: return "" elif self.requires_preheater: # Sources with preheater use return-temperature bus - return nodes + f" {heat_carrier} return-temperature" + return self.return_temperature_bus(nodes, heat_system) else: # Limited sources without preheater use the heat carrier bus directly - return nodes + f" {heat_carrier}" + return nodes + f" {heat_system}" def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: """ @@ -269,3 +270,75 @@ def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: return False else: return True + + def heat_carrier(self, heat_system) -> str: + """ + Returns the heat carrier for the heat source. + + Parameters + ---------- + heat_system : HeatSystem + The heat system for which to get the heat carrier. + + Returns + ------- + str + The heat carrier name. + """ + + return f"{heat_system} {self} heat" + + def medium_temperature_carrier(self, heat_system) -> str: + """ + Returns the medium temperature carrier for the heat source. + + Returns + ------- + str + The medium temperature carrier name. + """ + return f"{self.heat_carrier(heat_system)} medium-temperature" + + def return_temperature_carrier(self, heat_system) -> str: + """ + Returns the return temperature carrier for the heat source. + + Returns + ------- + str + The return temperature carrier name. + """ + return f"{self.heat_carrier(heat_system)} return-temperature" + + def medium_temperature_bus(self, nodes, heat_system) -> str: + """ + Returns the medium temperature bus for the heat source. + + Returns + ------- + str + The medium temperature bus name. + """ + return nodes + f" {self.medium_temperature_carrier(heat_system)}" + + def return_temperature_bus(self, nodes, heat_system) -> str: + """ + Returns the return temperature bus for the heat source. + + Returns + ------- + str + The return temperature bus name. + """ + return nodes + f" {self.return_temperature_carrier(heat_system)}" + + def resource_bus(self, nodes, heat_system) -> str: + """ + Returns the resource bus for the heat source. + + Returns + ------- + str + The resource bus name. + """ + return nodes + f" {self.heat_carrier(heat_system)}" diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6a065e473d..d76c1dab97 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3095,7 +3095,7 @@ def add_heat( nodes, suffix=f" {heat_system} water pits discharger", bus0=nodes + f" {heat_system} water pits", - bus1=nodes + f" {heat_system} ptes heat", + bus1=HeatSource.PTES.resource_bus(nodes, heat_system), carrier=f"{heat_system} water pits discharger", efficiency=costs.at[ "central water pit discharger", @@ -3207,7 +3207,7 @@ def add_heat( else costs.at[costs_name_heat_pump, "efficiency"] ) - heat_carrier = f"{heat_system} {heat_source} heat" + heat_carrier = heat_source.heat_carrier(heat_system) if heat_source.requires_generator: p_max_source = pd.read_csv( @@ -3224,7 +3224,7 @@ def add_heat( "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, capital_cost=capital_cost, @@ -3233,19 +3233,12 @@ def add_heat( ) if heat_source.requires_bus: - medium_temperature_carrier = f"{heat_carrier} medium-temperature" - return_temperature_carrier = f"{heat_carrier} return-temperature" - medium_temperature_bus = nodes + f" {medium_temperature_carrier}" - return_temperature_bus = nodes + f" {return_temperature_carrier}" - print(medium_temperature_bus, return_temperature_bus) - - # add resource + # 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, ) @@ -3259,33 +3252,31 @@ def add_heat( n.add( "Bus", - medium_temperature_bus, + heat_source.medium_temperature_bus(nodes, heat_system), location=nodes, - carrier=medium_temperature_carrier, + carrier=heat_source.medium_temperature_carrier(heat_system), ) n.add( "Bus", - return_temperature_bus, - suffix=return_temperature_carrier, + heat_source.return_temperature_bus(nodes, heat_system), location=nodes, - carrier=return_temperature_carrier, + carrier=heat_source.return_temperature_carrier(heat_system), ) n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat preheater", - bus0=medium_temperature_bus, + bus0=heat_source.medium_temperature_bus(nodes, heat_system), bus1=nodes + f" {heat_system} heat", - bus2=return_temperature_bus, + bus2=heat_source.return_temperature_bus(nodes, heat_system), efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, p_max_pu=preheater_utilisation_profile / preheater_utilisation_profile.clip(lower=0.001), carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, - marginal_cost=-0.3, ) direct_utilisation_profile = ( @@ -3302,17 +3293,16 @@ def add_heat( "Link", nodes, suffix=f" {heat_system} {heat_source} heat utilisation", - bus0=nodes + f" {heat_carrier}", + bus0=heat_source.resource_bus(nodes, heat_system), bus1=nodes + f" {heat_system} heat", - bus2=medium_temperature_bus, + bus2=heat_source.medium_temperature_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, - marginal_cost=0.1, ) - bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_carrier) + bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_system) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) if heat_source.requires_heat_pump( @@ -3339,6 +3329,7 @@ def add_heat( ) if options["resistive_heaters"]: + ptes_heat_source = HeatSource.PTES key = f"{heat_system.central_or_decentral} resistive heater" if ( @@ -3368,7 +3359,7 @@ def add_heat( suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} resistive heat", - bus2=nodes + f" {heat_system} ptes heat medium-temperature", + bus2=ptes_heat_source.medium_temperature_bus(nodes, heat_system), efficiency=ptes_boost_per_discharge_profiles / (ptes_boost_per_discharge_profiles + 1), efficiency2=1 / (ptes_boost_per_discharge_profiles + 1), From f1edf02fce4c029610146528f58ee23af510b7ff Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Tue, 25 Nov 2025 18:03:30 +0100 Subject: [PATCH 17/65] update docs --- .../build_heat_source_utilisation_profiles.py | 145 ++++++++--- .../ptes_temperature_approximator.py | 63 +++-- scripts/build_ptes_operations/run.py | 83 ++++--- scripts/definitions/heat_source.py | 229 ++++++++++++------ 4 files changed, 357 insertions(+), 163 deletions(-) diff --git a/scripts/build_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py index 1cbfe42905..d3695b4d07 100644 --- a/scripts/build_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -2,16 +2,58 @@ # # 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`). +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_temperatures_base_s_{clusters}_{planning_horizons}.nc`: Central heating forward temperature profiles +- ``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//direct_heat_source_utilisation_profiles_base_s_{clusters}_{planning_horizons}.nc`: Direct heat source utilisation profiles +- ``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 @@ -27,6 +69,29 @@ 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: @@ -48,20 +113,25 @@ def get_direct_utilisation_profile( source_temperature: float | xr.DataArray, forward_temperature: xr.DataArray ) -> xr.DataArray | float: """ - Get the direct heat source utilisation profile. + Calculate when a heat source can directly supply district heating. - 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`. + Direct utilisation is possible when the source temperature meets or exceeds + the required forward temperature of the district heating network. - Returns: - ------- - xr.DataArray | float - The direct heat source utilisation profile. + 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) @@ -73,22 +143,37 @@ def get_preheater_utilisation_profile( heat_source_cooling: float, ) -> 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`. - return_temperature: xr.DataArray - The central heating return temperature profiles. If `xarray`, indexed by `time` and `region`. - - Returns: + 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 accounts for the required cooling margin (heat_source_cooling) + to ensure adequate heat transfer: + + efficiency = heat_source_cooling / (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 + Required temperature drop (K) when extracting heat from the source, + ensuring adequate heat exchanger performance. + + Returns ------- - xr.DataArray | float - The direct heat source utilisation profile. - + 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) diff --git a/scripts/build_ptes_operations/ptes_temperature_approximator.py b/scripts/build_ptes_operations/ptes_temperature_approximator.py index 6445ae72f4..61140ef31d 100644 --- a/scripts/build_ptes_operations/ptes_temperature_approximator.py +++ b/scripts/build_ptes_operations/ptes_temperature_approximator.py @@ -32,6 +32,10 @@ class PtesTemperatureApproximator: 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__( @@ -65,6 +69,11 @@ def __init__( 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 @@ -86,8 +95,9 @@ def top_temperature_profile(self) -> xr.DataArray: """ PTES top layer temperature profile. - Returns either the forward temperature (if top_temperature == 'forward') - or a constant temperature profile (if top_temperature is a numeric value). + 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 ------- @@ -96,10 +106,13 @@ def top_temperature_profile(self) -> xr.DataArray: """ if self.top_temperature == "forward": logger.info( - f"PTES top temperature profile: Using dynamic forward temperature from district heating network " - f"(shape: {self.forward_temperature.shape}, range: {float(self.forward_temperature.min().values):.1f}°C to {float(self.forward_temperature.max().values):.1f}°C)" + 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, ) - return self.forward_temperature elif isinstance(self.top_temperature, (int, float)): logger.info( f"PTES top temperature profile: Using constant temperature of {self.top_temperature}°C " @@ -146,14 +159,13 @@ def bottom_temperature_profile(self) -> xr.DataArray: @property def e_max_pu(self) -> xr.DataArray: """ - Calculate the normalized delta T for TES capacity in relation to - max and min temperature. + Calculate e_max_pu for PTES as design_temperature_delta / actual_temperature_delta. Returns ------- xr.DataArray Normalized delta T values between 0 and 1, representing the - available storage capacity as a percentage of maximum capacity. + available storage capacity as a fraction of maximum design capacity. If temperature_dependent_capacity is False, returns constant capacity of 1.0. """ if self.temperature_dependent_capacity: @@ -235,38 +247,39 @@ def boost_per_discharge(self) -> xr.DataArray: @property def boost_per_charge(self) -> xr.DataArray: """ - Calculate how much of the total energy needed to fill the PTES to its - maximum capacity has already been delivered by charging up to the forward - temperature, versus how much extra energy remains to reach the maximum. + 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 - maximum top temperature, the total thermal energy required is split into: + design top temperature, the total thermal energy required is split into: - Q_charge = Ṽ·ρ·cₚ·(T_forward − T_bottom) + Q_charge = Ṽ·ρ·cₚ·(T_forward − T_bottom) Q_boost = Ṽ·ρ·cₚ·(T_top − T_forward) - - Q_forward is the energy already delivered by charging to the forward setpoint. - - Q_boosting is the extra boost energy still needed to reach maximum capacity. + - 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 delivered energy to remaining boost energy: + Defining α as the ratio of boost energy to charge energy: α = Q_boost / Q_charge - = (T_top − T_forward) / - (T_forward − T_return) - - This ratio quantifies the share of the total charge process that has - already been completed (via Q_forward) relative to what is still - required (Q_boosting) to hit the maximum PTES top temperature. + = (T_top − T_forward) / (T_forward − T_return) - Wherever the forward temperature meets or exceeds the maximum, α is set - to zero since no further boost is needed. + 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 already delivered by charging. + The ratio of additional boost energy needed to the energy delivered by charging. """ if self.charge_boosting_required: # Get the max top temperature value diff --git a/scripts/build_ptes_operations/run.py b/scripts/build_ptes_operations/run.py index e67da20ce6..8f75a65449 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -2,49 +2,66 @@ # # 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. -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). +This script calculates temperature and capacity profiles for pit thermal energy +storage systems integrated with district heating networks. It determines: -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. +1. **Top temperature profiles**: The operational top layer temperature, either + following the district heating forward temperature (clipped to design limits) + or a constant value. + +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: - temperature_dependent_capacity: - charge_boosting_required: - discharge_resistive_boosting: - max_top_temperature: - min_bottom_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//central_heating_forward_temperature_profiles.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_profile.nc` - Clipped PTES top temperature profile (in °C). -- `resources//ptes_e_max_pu_profile.nc` - Normalized PTES capacity profiles. -- `resources//boost_per_discharge_profile.nc` (conditional): - Discharge temperature boost ratio time series (only if discharge_resistive_boosting is enabled). - -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 @@ -70,17 +87,15 @@ set_scenario_config(snakemake) - # Load temperature profiles logger.info( - "Loading district heating temperature profiles and approximating PTES temperatures" + "Loading district heating temperature profiles and calculating PTES operational profiles" ) logger.info(f"PTES configuration: {snakemake.params}") - # Discharge boosting is "required" only if boosting via resistive heaters is enabled - # if you'd like to model boosting via heat pumps, add "ptes" to central heating heat sources and set temperatures accordingly + # 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 - # Initialize unified PTES temperature class ptes_temperature_approximator = PtesTemperatureApproximator( forward_temperature=xr.open_dataarray( snakemake.input.central_heating_forward_temperature_profiles diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index c376f6e699..7002edac5c 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -10,49 +10,41 @@ class HeatSource(Enum): """ - Enumeration representing different heat sources for heat pumps. + 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. + Geothermal heat source with constant temperature. RIVER_WATER : str - River water heat source. + River water heat source with time-varying temperature. SEA_WATER : str - Sea water heat source. + Sea water heat source (treated as inexhaustible). AIR : str - Air heat source. + Ambient air heat source (inexhaustible). GROUND : str - Ground heat source. - PTES: str - Pit Thermal Energy Storage as heat source. - - Methods - ------- - __str__() - Returns the string representation of the heat source. - constant_temperature_celsius() - Returns the constant temperature in Celsius for the heat source. - is_limited() - Returns whether the heat source is limited (vs. inexhaustible). - requires_generator() - Returns whether the heat source requires a generator. - requires_preheater() - Returns whether the heat source requires a preheater. - supports_direct_utilisation() - Returns whether the heat source supports direct heat utilisation. - get_capital_cost(costs, overdim_factor, heat_system) - Returns the capital cost for the heat source generator. - get_lifetime(costs, heat_system) - Returns the lifetime for the heat source generator. - get_heat_pump_bus2(nodes, heat_system, heat_carrier) - Returns the bus2 configuration for the heat pump link. - get_heat_pump_efficiency2(cop_heat_pump) - Returns the efficiency2 for the heat pump link. - get_efficiency_pre_heater(efficiency_direct_utilisation) - Returns the efficiency for the preheater link. - get_efficiency_direct_utilisation(direct_heat_profile, nodes, n) - Returns the efficiency for direct heat utilisation. + 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" @@ -79,12 +71,15 @@ def __str__(self) -> str: @property def has_constant_temperature(self) -> bool: """ - Returns the constant temperature in Celsius for the heat source. + 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 if the heat source has a constant temperature else False. + True for geothermal, False for all other sources. """ if self == HeatSource.GEOTHERMAL: return True @@ -116,12 +111,15 @@ def requires_bus(self) -> bool: @property def requires_generator(self) -> bool: """ - Returns whether the heat source requires a generator. + 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 if the heat source requires a generator, False otherwise. + True for geothermal and river_water, False otherwise. """ if self in [HeatSource.GEOTHERMAL, HeatSource.RIVER_WATER]: return True @@ -131,14 +129,18 @@ def requires_generator(self) -> bool: @property def requires_preheater(self) -> bool: """ - Returns whether the heat source requires a pre-heater. + Check if the heat source uses preheating when below forward temperature. + + Preheating allows intermediate-temperature sources to warm the return + flow before a heat pump provides the final temperature lift, improving + overall system efficiency. Returns ------- bool - True if the heat source requires a pre-heater, False otherwise. + True for PTES, False otherwise. """ - if self in [HeatSource.GEOTHERMAL, HeatSource.PTES]: + if self in [HeatSource.PTES]: return True else: return False @@ -201,16 +203,22 @@ def get_lifetime(self, costs, heat_system) -> float: def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: """ - Returns the bus2 configuration for the heat pump link. + 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) + - Sources with preheater: Return-temperature bus (post-preheat) + - Other limited sources: Resource bus directly Parameters ---------- nodes : pd.Index or list The nodes for which to generate the bus name. - requires_preheater : bool - Whether the heat source requires a preheater. - heat_carrier : str - The heat carrier name. + heat_system : str + The heat system identifier (e.g., 'urban central'). Returns ------- @@ -225,11 +233,35 @@ def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: return self.return_temperature_bus(nodes, heat_system) else: # Limited sources without preheater use the heat carrier bus directly - return nodes + f" {heat_system}" + return self.resource_bus(nodes, heat_system) def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: """ - Returns the efficiency2 for the heat pump link. + 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). + + 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 ---------- @@ -239,29 +271,38 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: Returns ------- float or pd.Series - The efficiency2 value for the heat pump. + 1.0 for inexhaustible sources (no resource tracking needed), + 1 - 1/COP for limited sources (tracks heat drawn from source bus). + + See Also + -------- + prepare_sector_network.add_heat : Creates heat pump links with this efficiency. """ if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: - # Inexhaustible sources (air, ground, sea-water) don't have a bus2 - # Inexhaustible sources (air, ground, sea_water) have efficiency2 = 1 - # (no resource consumption from dummy bus) + # Inexhaustible sources don't need resource tracking return 1.0 else: - # Limited heat sources consume heat from the resource bus (either pre-chilled or direct) - # efficiency2 represents the fraction of heat drawn from the source - # This is 1 - (1/COP), representing (COP-1)/COP + # Limited sources: efficiency2 = 1 - 1/COP for inverted link configuration return 1 - (1 / cop_heat_pump.clip(lower=0.001)) def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: """ - Returns whether the heat source requires a heat pump. + 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 - True if the heat source requires a heat pump, False otherwise. + 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; " @@ -273,72 +314,112 @@ def requires_heat_pump(self, ptes_discharge_resistive_boosting: bool) -> bool: def heat_carrier(self, heat_system) -> str: """ - Returns the heat carrier for the heat source. + Get the carrier name for heat from this source. Parameters ---------- - heat_system : HeatSystem - The heat system for which to get the heat carrier. + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). Returns ------- str - The heat carrier name. + Carrier name in format '{heat_system} {source} heat', + e.g., 'urban central ptes heat'. """ - return f"{heat_system} {self} heat" def medium_temperature_carrier(self, heat_system) -> str: """ - Returns the medium temperature carrier for the heat source. + 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 - The medium temperature carrier name. + Carrier name with '-medium-temperature' suffix in format '{heat_system} {source} medium-temperature'. """ return f"{self.heat_carrier(heat_system)} medium-temperature" def return_temperature_carrier(self, heat_system) -> str: """ - Returns the return temperature carrier for the heat source. + Get the carrier name for fully-cooled heat from this source. + + Represents heat at return temperature after preheating, ready for + final temperature lift by heat pump. + + Parameters + ---------- + heat_system : HeatSystem or str + The heat system (e.g., 'urban central'). Returns ------- str - The return temperature carrier name. + Carrier name with '-return-temperature' suffix in format '{heat_system} {source} return-temperature'. """ return f"{self.heat_carrier(heat_system)} return-temperature" def medium_temperature_bus(self, nodes, heat_system) -> str: """ - Returns the medium temperature bus for the heat source. + 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 - The medium temperature bus name. + Bus name combining nodes with medium-temperature carrier in format 'nodes + {heat_system} {source} medium-temperature'. """ return nodes + f" {self.medium_temperature_carrier(heat_system)}" def return_temperature_bus(self, nodes, heat_system) -> str: """ - Returns the return temperature bus for the heat source. + Get bus name for fully-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 - The return temperature bus name. + Bus name combining nodes with return-temperature carrier in format 'nodes + {heat_system} {source} return-temperature'. """ return nodes + f" {self.return_temperature_carrier(heat_system)}" def resource_bus(self, nodes, heat_system) -> str: """ - Returns the resource bus for the heat source. + Get the primary resource bus for heat from this source. + + 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 - The resource bus name. + Bus name combining nodes with heat carrier in format 'nodes + {heat_system} {source} heat'. """ return nodes + f" {self.heat_carrier(heat_system)}" From bc5bb8beca7ff2088236f8cebd7ae1eb802bb3b8 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Tue, 25 Nov 2025 18:04:16 +0100 Subject: [PATCH 18/65] move heat generator down to surpress bus-not-defined warning --- scripts/prepare_sector_network.py | 46 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d76c1dab97..5371ef0643 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3209,29 +3209,6 @@ def add_heat( heat_carrier = heat_source.heat_carrier(heat_system) - if heat_source.requires_generator: - p_max_source = pd.read_csv( - heat_source_profile_files[heat_source.value], - index_col=0, - ).squeeze()[nodes] - - 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=heat_source.resource_bus(nodes, heat_system), - carrier=heat_carrier, - p_nom_extendable=True, - capital_cost=capital_cost, - lifetime=lifetime, - p_max_pu=p_max_source, - ) - if heat_source.requires_bus: # add heat source carrier and bus n.add("Carrier", heat_carrier) @@ -3302,6 +3279,29 @@ def add_heat( p_nom_extendable=True, ) + if heat_source.requires_generator: + p_max_source = pd.read_csv( + heat_source_profile_files[heat_source.value], + index_col=0, + ).squeeze()[nodes] + + 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=heat_source.resource_bus(nodes, heat_system), + carrier=heat_carrier, + p_nom_extendable=True, + capital_cost=capital_cost, + lifetime=lifetime, + p_max_pu=p_max_source, + ) + bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_system) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) From 69f6fd05a2fc0e73e595c9df0f797f999807bcb3 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 27 Nov 2025 12:40:28 +0100 Subject: [PATCH 19/65] update docs --- scripts/build_cop_profiles/run.py | 143 ++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 17 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index 33ab52b67b..816bac3158 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_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 @@ -61,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( @@ -110,6 +138,33 @@ def get_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: try: @@ -132,9 +187,35 @@ 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. + + 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. + """ heat_source = HeatSource(heat_source_name) if heat_source.requires_preheater: - # pre-heater is only used when source temperature is below return temperature, otherwise sink inlet is at return temperature and source inlet is at source temperature + # 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 central_heating_return_temperature.where( central_heating_return_temperature < source_temperature, source_temperature ) @@ -148,9 +229,37 @@ def get_sink_inlet_temperature( central_heating_return_temperature: xr.DataArray, central_heating_forward_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. + + 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. + central_heating_forward_temperature : xr.DataArray + District heating forward (supply) temperature in Celsius. + + Returns + ------- + float | xr.DataArray + Effective sink inlet temperature for the heat pump in Celsius. + """ heat_source = HeatSource(heat_source_name) if heat_source.requires_preheater: - # pre-heater is only used when source temperature is below return temperature, otherwise sink inlet is at return temperature and source inlet is at source temperature + # When source temperature > return temperature, preheater is used: + # preheater raises return flow, heat pump inlet is at forward temp. + # When source temperature <= return temperature, no preheating: + # heat pump inlet is at return temperature. return central_heating_forward_temperature.where( central_heating_return_temperature < source_temperature, central_heating_return_temperature, From 5018697839ba654800c23f9dd446efb1614b101c Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 27 Nov 2025 14:09:45 +0100 Subject: [PATCH 20/65] have geothermal require preheating --- scripts/definitions/heat_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 7002edac5c..5955001680 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -140,7 +140,7 @@ def requires_preheater(self) -> bool: bool True for PTES, False otherwise. """ - if self in [HeatSource.PTES]: + if self in [HeatSource.PTES, HeatSource.GEOTHERMAL]: return True else: return False From 0df6f69c68d81c72a930331d076c18fd0d615d13 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 27 Nov 2025 14:10:18 +0100 Subject: [PATCH 21/65] set PTES heat-source cooling to T_top - T_bottom --- rules/build_sector.smk | 32 ++++++++++++ .../build_heat_source_utilisation_profiles.py | 50 ++++++++++++++++++- scripts/build_ptes_operations/run.py | 4 ++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 484f18eef6..6c22527646 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -539,6 +539,34 @@ def input_heat_source_temperature( return file_names +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]: """ Generate input file paths for seawater temperature data. @@ -685,6 +713,9 @@ rule build_ptes_operations: 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" ), @@ -716,6 +747,7 @@ rule build_heat_source_utilisation_profiles: ), 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" ), diff --git a/scripts/build_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py index d3695b4d07..ed639b5f2a 100644 --- a/scripts/build_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -184,6 +184,50 @@ def get_preheater_utilisation_profile( ) +def get_heat_source_cooling( + heat_source_name: str, + default_heat_source_cooling: float, + snakemake_input: dict, +) -> float | xr.DataArray: + """ + Get the effective heat source cooling (temperature drop) for a heat source. + + For PTES, heat source cooling equals the temperature difference between + top and bottom layers (top_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. + + 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." + ) + ptes_top_temperature = xr.open_dataarray(snakemake_input["temp_ptes"]) + ptes_bottom_temperature = xr.open_dataarray(snakemake_input["temp_ptes_bottom"]) + return ptes_top_temperature - ptes_bottom_temperature + return default_heat_source_cooling + + if __name__ == "__main__": if "snakemake" not in globals(): from scripts._helpers import mock_snakemake @@ -229,7 +273,11 @@ def get_preheater_utilisation_profile( ), forward_temperature=central_heating_forward_temperature, return_temperature=central_heating_return_temperature, - heat_source_cooling=snakemake.params.heat_source_cooling, + heat_source_cooling=get_heat_source_cooling( + heat_source_name=heat_source_key, + default_heat_source_cooling=snakemake.params.heat_source_cooling, + snakemake_input=snakemake.input, + ), ).assign_coords(heat_source=heat_source_key) for heat_source_key in heat_sources ], diff --git a/scripts/build_ptes_operations/run.py b/scripts/build_ptes_operations/run.py index 8f75a65449..347c5e5efe 100644 --- a/scripts/build_ptes_operations/run.py +++ b/scripts/build_ptes_operations/run.py @@ -116,6 +116,10 @@ snakemake.output.ptes_top_temperature_profiles ) + ptes_temperature_approximator.bottom_temperature_profile.to_netcdf( + snakemake.output.ptes_bottom_temperature_profiles + ) + ptes_temperature_approximator.e_max_pu.to_netcdf( snakemake.output.ptes_e_max_pu_profiles ) From 65a8f935710ecee8b7ff67c9b6432a1c8ad4a0dd Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 27 Nov 2025 16:52:49 +0100 Subject: [PATCH 22/65] Correct preheater profiles --- .../build_heat_source_utilisation_profiles.py | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/scripts/build_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py index ed639b5f2a..267fb6161a 100644 --- a/scripts/build_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -149,10 +149,10 @@ def get_preheater_utilisation_profile( 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 accounts for the required cooling margin (heat_source_cooling) - to ensure adequate heat transfer: + The efficiency represents the fraction of heat extracted from the source that + goes into preheating (vs. the additional cooling through the heat pump): - efficiency = heat_source_cooling / (T_source - T_return + heat_source_cooling) + efficiency = (T_source - T_return) / (T_source - T_return + heat_source_cooling) Parameters ---------- @@ -165,9 +165,9 @@ def get_preheater_utilisation_profile( return_temperature : xr.DataArray District heating return temperature profiles in °C, indexed by (time, name). - heat_source_cooling : float - Required temperature drop (K) when extracting heat from the source, - ensuring adequate heat exchanger performance. + 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 ------- @@ -178,22 +178,23 @@ def get_preheater_utilisation_profile( return xr.where( (source_temperature < forward_temperature) * (source_temperature > return_temperature), - heat_source_cooling + (source_temperature - return_temperature) / (source_temperature - return_temperature + heat_source_cooling), 0.0, ) -def get_heat_source_cooling( +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 effective heat source cooling (temperature drop) for a heat source. + Get the additional heat source cooling (temperature drop) through the heat pump for a heat source. - For PTES, heat source cooling equals the temperature difference between - top and bottom layers (top_temperature - bottom_temperature), which can + 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 @@ -204,6 +205,8 @@ def get_heat_source_cooling( 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 ------- @@ -222,9 +225,12 @@ def get_heat_source_cooling( "PTES heat source requires bottom temperature profile " "(temp_ptes_bottom) to calculate heat source cooling." ) - ptes_top_temperature = xr.open_dataarray(snakemake_input["temp_ptes"]) + 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 ptes_top_temperature - ptes_bottom_temperature + return return_temperature - ptes_bottom_temperature return default_heat_source_cooling @@ -273,10 +279,11 @@ def get_heat_source_cooling( ), forward_temperature=central_heating_forward_temperature, return_temperature=central_heating_return_temperature, - heat_source_cooling=get_heat_source_cooling( + 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 From 084a1a351184a6a05c2fa8c7ea33696712defe34 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 8 Dec 2025 14:21:16 +0100 Subject: [PATCH 23/65] update test config --- config/test/config.myopic.yaml | 7 ++----- config/test/config.overnight.yaml | 7 ++----- config/test/config.perfect.yaml | 7 ++----- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/config/test/config.myopic.yaml b/config/test/config.myopic.yaml index 207408e7ca..713c4240fc 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 d322d8b4d7..90cbfbd764 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -62,18 +62,15 @@ sector: gas_network: true H2_retrofit: true 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 035aba3ebe..54b7e83645 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: From 4976e0932cad11a2a6c6cbcdb715649d7efcf00c Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 8 Dec 2025 14:21:57 +0100 Subject: [PATCH 24/65] update perfect/brownfield runs --- rules/solve_perfect.smk | 2 +- scripts/add_existing_baseyear.py | 84 +++++++++++++++++++------------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index 8586d44366..3318886eea 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -8,7 +8,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_existing_baseyear.py b/scripts/add_existing_baseyear.py index 811ba0d414..46acc048b8 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 @@ -482,7 +483,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, @@ -511,8 +512,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 @@ -580,40 +581,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 > 0: + heat_source = HeatSource(heat_source) + + if not heat_source.baseyear_capacities_supported(): + 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, + 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", @@ -783,7 +799,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[ From 56021316505b59dd2f35b4cb0a03003fe29f3a9b Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 8 Dec 2025 16:36:35 +0100 Subject: [PATCH 25/65] update geothermal to reflect dT assumed in data source --- rules/build_sector.smk | 23 ++++- scripts/build_geothermal_heat_potential.py | 115 ++++++++++++++++++++- scripts/prepare_sector_network.py | 18 +++- 3 files changed, 148 insertions(+), 8 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 6c22527646..e35e5d9d0b 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -308,20 +308,33 @@ rule build_geothermal_heat_potential: "geothermal", "ignore_missing_regions", ), + heat_source_cooling=config_provider( + "sector", "district_heating", "heat_source_cooling" + ), input: isi_heat_potentials="data/isi_heat_utilisation_potentials.xlsx", 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="data/lau_regions.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" @@ -1449,7 +1462,9 @@ def input_heat_source_power(w): for heat_source_name in heat_sources: if HeatSource(heat_source_name).requires_generator: result[heat_source_name] = resources( - "heat_source_power_" + heat_source_name + "_base_s_{clusters}.csv" + "heat_source_power_" + + heat_source_name + + "_base_s_{clusters}_{planning_horizons}.csv" ) return result diff --git a/scripts/build_geothermal_heat_potential.py b/scripts/build_geothermal_heat_potential.py index df573866b7..d9e8d6097c 100644 --- a/scripts/build_geothermal_heat_potential.py +++ b/scripts/build_geothermal_heat_potential.py @@ -40,6 +40,7 @@ import geopandas as gpd import pandas as pd +import xarray as xr from scripts._helpers import configure_logging, set_scenario_config @@ -58,6 +59,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.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 +304,7 @@ def get_heat_source_power( snakemake = mock_snakemake( "build_geothermal_heat_potential", clusters=48, + planning_horizons=2040, ) configure_logging(snakemake) @@ -267,4 +362,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/prepare_sector_network.py b/scripts/prepare_sector_network.py index 5371ef0643..39dfd5e78f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3280,10 +3280,18 @@ def add_heat( ) if heat_source.requires_generator: - p_max_source = pd.read_csv( + # Load time-varying heat source power (MW) + heat_source_power = pd.read_csv( heat_source_profile_files[heat_source.value], index_col=0, - ).squeeze()[nodes] + 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 @@ -3297,9 +3305,10 @@ def add_heat( 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_max_pu=p_max_source, + p_max_pu=p_max_pu, ) bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_system) @@ -3360,8 +3369,11 @@ def add_heat( bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} resistive heat", bus2=ptes_heat_source.medium_temperature_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=-ptes_boost_per_discharge_profiles From 132639fb733957e683d95534f0142971b1a29b29 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Tue, 9 Dec 2025 10:37:01 +0100 Subject: [PATCH 26/65] update docs of build_geothermal_heat_potential --- scripts/build_geothermal_heat_potential.py | 55 +++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/scripts/build_geothermal_heat_potential.py b/scripts/build_geothermal_heat_potential.py index d9e8d6097c..4522d77e93 100644 --- a/scripts/build_geothermal_heat_potential.py +++ b/scripts/build_geothermal_heat_potential.py @@ -2,38 +2,69 @@ # # 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): + scale_factor = (source_temperature - return_temperature) / 15 K + +b) If forward_temperature >= source_temperature > return_temperature (preheating): + scale_factor = (source_temperature - return_temperature + heat_source_cooling) / 15 K + +c) If source_temperature <= return_temperature (heat pump only): + 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 @@ -139,7 +170,7 @@ def scale_heat_source_power( # Convert heat_source_power to DataArray and broadcast heat_source_power_da = xr.DataArray( - heat_source_power.values, + heat_source_power.squeeze().values, dims=["name"], coords={"name": regions}, ) From c248931f6c41c71460f5447349311d8f58e4742e Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Tue, 9 Dec 2025 13:53:02 +0100 Subject: [PATCH 27/65] update geothermal preheater colour --- config/plotting.default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index e3aaed13ee..a2d5c7955b 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -569,7 +569,7 @@ plotting: other: '#000000' geothermal: '#ba91b1' geothermal heat: '#ba91b1' - geothermal heat preheater: '#ba91b1' + geothermal heat preheater: '#f2bbe6' geothermal district heat: '#d19D00' geothermal organic rankine cycle: '#ffbf00' urban central geothermal heat pre-chilled: '#ba91b1' From 2fd4fd83b4b12198878319465074e5ecec2223ff Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 10 Dec 2025 16:24:33 +0100 Subject: [PATCH 28/65] fix myopic runs --- rules/solve_myopic.smk | 2 +- scripts/add_existing_baseyear.py | 6 +++--- scripts/build_existing_heating_distribution.py | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 46d4efbd2e..97798f5926 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.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_existing_baseyear.py b/scripts/add_existing_baseyear.py index 46acc048b8..98520198cf 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -589,10 +589,10 @@ def add_heating_capacities_installed_before_baseyear( * ratio ) - if p_nom > 0: + if p_nom.sum() > 0: heat_source = HeatSource(heat_source) - if not heat_source.baseyear_capacities_supported(): + 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." ) @@ -602,7 +602,7 @@ def add_heating_capacities_installed_before_baseyear( efficiency = ( heat_pump_cop.sel( heat_system=heat_system.system_type.value, - heat_source=heat_source, + heat_source=heat_source.value, name=nodes, ) .to_pandas() 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) From 24a70486fcb7c225b30a2332bb60288ccd9795fe Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 10 Dec 2025 19:05:02 +0100 Subject: [PATCH 29/65] fix bugs resulting from merge --- rules/build_sector.smk | 2 +- scripts/prepare_sector_network.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index d81ca93ca3..226865ee9b 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -785,7 +785,7 @@ rule build_heat_source_utilisation_profiles: "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: diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 63d40f7ee6..0b0de7c95f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3030,7 +3030,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( @@ -3300,7 +3300,6 @@ def add_heat( carrier=heat_source.return_temperature_carrier(heat_system), ) - breakpoint() n.add( "Link", nodes, From 5129589e3cd011e8a2e0f203762e1a14ff1d7d1b Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 10 Dec 2025 19:10:59 +0100 Subject: [PATCH 30/65] update docs and release notes --- doc/configtables/sector.csv | 41 ++++++++++++++++++------------------- doc/release_notes.rst | 2 ++ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index 24c9978546..ebf9660abf 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -20,12 +20,14 @@ district_heating,--,, -- -- rolling_window_ambient_temperature, h, int, Rolling window size for averaging ambient temperature when approximating supply temperature -- -- relative_annual_temperature_reduction,, float, Relative annual reduction of district heating forward and return temperature - defaults to 0.01 (1%) -- ptes,,, --- -- dynamic_capacity,--,"{true, false}",Add option for dynamic temperature-dependent energy capacity of pit storage in district heating --- -- supplemental_heating,,, --- -- -- enable,--,"{true, false}",Add option to enable supplemental heating of pit storage in district heating --- -- -- booster_heat_pump: true,--,"{true, false}",Add option to enable a booster heat pump for supplemental heating of pit storage in district heating --- -- max_top_temperature,C,float,The maximum top temperature of the pit storage according to DEA technology catalogue (2018) --- -- min_bottom_temperature,C,float,The minimum bottom temperature of the pit storage according to DEA technology catalogue (2018) +-- -- enable,--,"{true, false}",Enable investments in pit thermal energy storage (PTES) in district heating +-- -- temperature_dependent_capacity,--,"{true, false}",Enable dynamic temperature-dependent energy capacity of PTES based on actual top/bottom temperatures +-- -- charge_boosting_required,--,"{true, false}",Require boosting when charging PTES (placeholder for future feature) +-- -- discharge_resistive_boosting,--,"{true, false}",Use resistive heaters instead of heat pumps for boosting PTES discharge when needed +-- -- top_temperature,C,float,Operating top temperature of the PTES +-- -- bottom_temperature,C,float,Operating bottom temperature of the PTES +-- -- design_top_temperature,C,float,Design top temperature used for sizing PTES capacity (from DEA technology catalogue) +-- -- design_bottom_temperature,C,float,Design bottom temperature used for sizing PTES capacity (from DEA technology catalogue) -- ates,,, -- -- enable,--,"{true, false}",Enable investments in aquifer thermal energy pit storage in district heating -- -- suitable_aquifer_types,--,List of aquifer types assumed suitable for ATES. Must be subset of [Highly productive porous aquifers'; @@ -41,27 +43,24 @@ district_heating,--,, -- -- dh_area_buffer,m,float,Suitable aquifers must be within this distance to district heating areas. -- -- capex_as_fraction_of_geothermal_heat_source,,float,The capital expenditure of ATES chargers/dischargers as a fraction of the geothermal heat source per-MWh CAPEX. -- -- recovery_factor,,float,The recovery factor of the aquifer (1- yearly_losses). +-- -- marginal_cost_charger,,float,Marginal cost for ATES charging operations. +-- -- ignore_missing_regions,--,"{true, false}",Ignore regions without suitable aquifer data and fill with zeros instead of raising an error. -- heat_source_cooling,K,float,Cooling of heat source for heat pumps -- heat_pump_cop_approximation,,, -- -- refrigerant,--,"{ammonia, isobutane}",Heat pump refrigerant assumed for COP approximation -- -- heat_exchanger_pinch_point_temperature_difference,K,float,Heat pump pinch point temperature difference in heat exchangers assumed for approximation. -- -- isentropic_compressor_efficiency,--,float,Isentropic efficiency of heat pump compressor assumed for approximation. Must be between 0 and 1. -- -- heat_loss,--,float,Heat pump heat loss assumed for approximation. Must be between 0 and 1. --- -- min_delta_t_lift,--,float,"Minimum feasible temperature lift for heat pumps, used to approximate technical limits in heat pump operation. This value accounts for practical constraints in heat pump design." --- -- min_delta_t_lift,--,float,"Minimum feasible temperature lift for heat pumps, used to approximate technical limits in heat pump operation. This value accounts for practical constraints in heat pump design." --- limited_heat_sources,--,,Dictionary with names of limited heat sources (not air). Must be `river_water` / `geothermal` or another heat source in `Manz et al. 2024 `__) - --- -- river_water,-,Name of the heat source. Must be the same as in ``heat_pump_sources``, --- -- -- constant_temperature_celsius,°C,heat source temperature, --- -- -- ignore_missing_regions,--,Boolean,Ignore missing regions in the data and fill with zeros or raise an error --- direct_utilisation_heat_sources,--,List of heat sources for direct heat utilisation in district heating. Must be in the keys of `heat_utilisation_potentials` (e.g. ``geothermal``), --- temperature_limited_stores,,Dictionary with names for stores used as limited heat sources --- -- ptes,-,Name of the heat source. Must be the same as in ``heat_pump_sources`` --- dh_area_buffer,m,float,The buffer by which dh_area shapes from Manz et al. are increased --- heat_pump_sources,--,, --- -- urban central,--,"List of heat sources for heat pumps in urban central heating. Must be one of [air, river_water, sea_water, geothermal]", --- -- urban decentral,--,"List of heat sources for heat pumps in urban decentral heating. Must be one of [air]", --- -- rural,--,"List of heat sources for heat pumps in rural heating. Must be one of [air, ground]", +-- -- min_delta_t_lift,K,float,"Minimum feasible temperature lift for heat pumps, used to approximate technical limits in heat pump operation. This value accounts for practical constraints in heat pump design." +-- dh_areas,,, +-- -- buffer,m,float,The buffer distance by which district heating area shapes from Manz et al. are expanded +-- -- handle_missing_countries,--,"{fill, error}",How to handle countries without district heating area data - 'fill' uses default values or 'error' raises an exception +-- geothermal,,, +-- -- constant_temperature_celsius,C,float,Constant temperature assumed for geothermal heat sources. Used for scaling heat potentials based on actual vs assumed temperature differences. +heat_sources,--,, +-- urban central,--,"List of heat sources for heat pumps in urban central heating. Must be one of [air, geothermal, ptes, river_water, sea_water]", +-- urban decentral,--,"List of heat sources for heat pumps in urban decentral heating. Must be one of [air]", +-- rural,--,"List of heat sources for heat pumps in rural heating. Must be one of [air, ground]", residential_heat,--,,Configuration options for residential heat demand-side management (DSM). See `smartEn DSM study `_ (Appendix A) for methodology. -- dsm,--,, -- -- enable,--,"{true, false}",Enable residential heat demand-side management that allows heating systems to provide flexibility by shifting demand within configurable time periods. Models building thermal mass as energy storage. diff --git a/doc/release_notes.rst b/doc/release_notes.rst index a405bc530a..f3a3f6a020 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -9,6 +9,8 @@ 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). + * Added existing biomass decentral/rural residential and services heating capacity. * Fix parsing in Swiss passenger cars data (https://github.com/PyPSA/pypsa-eur/pull/1934 and https://github.com/PyPSA/pypsa-eur/pull/1936). From a008b0a59069d4d55072074f651006f05571a021 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 10 Dec 2025 19:12:14 +0100 Subject: [PATCH 31/65] update default config --- config/config.default.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 5c0a7b482f..655b26c204 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -529,9 +529,9 @@ sector: enable: true temperature_dependent_capacity: false charge_boosting_required: false - discharge_resistive_boosting: false + discharge_resistive_boosting: true top_temperature: 90 - bottom_temperature: 10 + bottom_temperature: 35 design_top_temperature: 90 design_bottom_temperature: 35 ates: From a94d441c6f806731c90f8c363a3e112d9f4bab26 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 11 Dec 2025 11:00:08 +0100 Subject: [PATCH 32/65] fix: update heat source condition to check for geothermal type --- rules/build_sector.smk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 226865ee9b..9e38f9d197 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1502,7 +1502,7 @@ def input_heat_source_power(w): 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 From 47130e3c2bedff6a6e151b74bc23e2a2d04cbde1 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 11 Dec 2025 13:54:51 +0100 Subject: [PATCH 33/65] fix heat source power input function --- rules/build_sector.smk | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 9e38f9d197..25c4081756 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1502,12 +1502,19 @@ def input_heat_source_power(w): heat_sources = config_provider("sector", "heat_sources", "urban central")(w) for heat_source_name in heat_sources: - if HeatSource(heat_source_name) == HeatSource.GEOTHERMAL: - result[heat_source_name] = resources( - "heat_source_power_" - + heat_source_name - + "_base_s_{clusters}_{planning_horizons}.csv" - ) + 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 From 4f01590477d23aa85aa3c3fc3a8e7678f68d0438 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 11 Dec 2025 15:54:12 +0100 Subject: [PATCH 34/65] add dummy colours for river_water util/preheating --- config/plotting.default.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index 2cd870915a..4ebf2c322a 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -466,6 +466,8 @@ plotting: geothermal heat pump: '#4f2144' 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' From a17ed297b815f259e8f586fc0f63ad5917e8efa8 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 11 Dec 2025 15:54:30 +0100 Subject: [PATCH 35/65] don't build resistive booster if ptes:enable:false --- scripts/prepare_sector_network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 0a0b67cea6..0e58d0b429 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3404,6 +3404,7 @@ def add_heat( if ( heat_system == HeatSystem.URBAN_CENTRAL + and params.sector["ptes"]["enable"] == True and params.sector["district_heating"]["ptes"][ "discharge_resistive_boosting" ] From f0bea656ca7e8b937737a87646208a3f546fc9de Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Fri, 12 Dec 2025 14:51:59 +0100 Subject: [PATCH 36/65] handle case of empty heat sources --- scripts/build_cop_profiles/run.py | 7 +++++++ scripts/prepare_sector_network.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index 816bac3158..ac5c0519ff 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -289,6 +289,13 @@ def get_sink_inlet_temperature( cop_all_system_types = [] for heat_system_type, heat_sources in snakemake.params.heat_sources.items(): cop_this_system_type = [] + 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, diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 0e58d0b429..6d4c1b6dd5 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2873,12 +2873,19 @@ def add_heat( ) cop = xr.open_dataarray(cop_profiles_file) - heat_source_direct_utilisation_profile = xr.open_dataarray( - heat_source_direct_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 + + 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"] @@ -3404,7 +3411,7 @@ def add_heat( if ( heat_system == HeatSystem.URBAN_CENTRAL - and params.sector["ptes"]["enable"] == True + and params.sector["district_heating"]["ptes"]["enable"] == True and params.sector["district_heating"]["ptes"][ "discharge_resistive_boosting" ] From 972ab90eaf6a49d46fcabb300c18dac7a58ebfc6 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 7 Jan 2026 11:52:51 +0100 Subject: [PATCH 37/65] enable ptes and geothermal as heat sources by default --- config/config.default.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.default.yaml b/config/config.default.yaml index 655b26c204..79431d7970 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -560,6 +560,8 @@ sector: heat_sources: urban central: - air + - ptes + - geothermal urban decentral: - air rural: From 36110d5ffb8abc3ead389348d3e8fd157eb64811 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 7 Jan 2026 12:00:38 +0100 Subject: [PATCH 38/65] fix: clarify preheating logic in get_sink_inlet_temperature function --- scripts/build_cop_profiles/run.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index ac5c0519ff..40d0782ba8 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -238,6 +238,9 @@ def get_sink_inlet_temperature( 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 forward temp. + When source temperature <= return temperature, no preheating: heat pump inlet is at return temperature. + Parameters ---------- heat_source_name : str @@ -256,10 +259,6 @@ def get_sink_inlet_temperature( """ heat_source = HeatSource(heat_source_name) if heat_source.requires_preheater: - # When source temperature > return temperature, preheater is used: - # preheater raises return flow, heat pump inlet is at forward temp. - # When source temperature <= return temperature, no preheating: - # heat pump inlet is at return temperature. return central_heating_forward_temperature.where( central_heating_return_temperature < source_temperature, central_heating_return_temperature, @@ -321,7 +320,7 @@ def get_sink_inlet_temperature( 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( From 30cf8f8245879508d02a6bda856c2b8c856cf656 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 10:48:15 +0100 Subject: [PATCH 39/65] fix: update p_max_pu and p_min_pu calculations --- scripts/prepare_sector_network.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6d4c1b6dd5..d9ff488c4b 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3316,8 +3316,7 @@ def add_heat( bus2=heat_source.return_temperature_bus(nodes, heat_system), efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, - p_max_pu=preheater_utilisation_profile - / preheater_utilisation_profile.clip(lower=0.001), + p_max_pu=(preheater_utilisation_profile > 0).astype(float), carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, ) @@ -3397,9 +3396,7 @@ def add_heat( efficiency2=efficiency2_heat_pump, 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_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"], @@ -3445,8 +3442,7 @@ def add_heat( # (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=-ptes_boost_per_discharge_profiles - / ptes_boost_per_discharge_profiles.clip(lower=0.001), + p_min_pu=-(ptes_boost_per_discharge_profiles > 0).astype(float), carrier=f"{heat_system} water pits resistive booster", p_max_pu=0, ) From 439e3019e3aae6279f88247941e4460a852ceaba Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 10:49:30 +0100 Subject: [PATCH 40/65] fix: update PTES temperature parameters to allow for forward_temperature/return_temperature --- doc/configtables/sector.csv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index ebf9660abf..32c41a601a 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -24,8 +24,8 @@ district_heating,--,, -- -- temperature_dependent_capacity,--,"{true, false}",Enable dynamic temperature-dependent energy capacity of PTES based on actual top/bottom temperatures -- -- charge_boosting_required,--,"{true, false}",Require boosting when charging PTES (placeholder for future feature) -- -- discharge_resistive_boosting,--,"{true, false}",Use resistive heaters instead of heat pumps for boosting PTES discharge when needed --- -- top_temperature,C,float,Operating top temperature of the PTES --- -- bottom_temperature,C,float,Operating bottom temperature of the PTES +-- -- top_temperature,C,float or "forward",Operating top temperature of the PTES +-- -- bottom_temperature,C,float or "return",Operating bottom temperature of the PTES -- -- design_top_temperature,C,float,Design top temperature used for sizing PTES capacity (from DEA technology catalogue) -- -- design_bottom_temperature,C,float,Design bottom temperature used for sizing PTES capacity (from DEA technology catalogue) -- ates,,, From 77e451486e8f04a38bbac256be4f667d1a623717 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 10:50:46 +0100 Subject: [PATCH 41/65] fix: update parameter name from 'ttes' to 'ptes' in dynamic PTES capacity check --- scripts/add_brownfield.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 54785ce3ea..c71a2cb2f8 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -358,7 +358,7 @@ def update_dynamic_ptes_capacity( update_heat_pump_efficiency(n, n_p, year) - if snakemake.params.ttes 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( From ad3492c5c1b82f8c8c59445d71dd9e75841f5e86 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 10:51:53 +0100 Subject: [PATCH 42/65] docs: add notes on ideal heat exchangers to get_source_inlet_temperature function --- scripts/build_cop_profiles/run.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index 40d0782ba8..dc5046b66b 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -196,6 +196,10 @@ def get_source_inlet_temperature( 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 From ebac5b2c71f99614f5b2c29e109402776a21d015 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 11:02:48 +0100 Subject: [PATCH 43/65] fix: update requires_preheater docs --- scripts/definitions/heat_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 5955001680..460eab0f0a 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -138,7 +138,7 @@ def requires_preheater(self) -> bool: Returns ------- bool - True for PTES, False otherwise. + True for PTES and GEOTHERMAL, False otherwise. """ if self in [HeatSource.PTES, HeatSource.GEOTHERMAL]: return True From 815a820c66ff72cbbefa47d4e6cfb9efce35b7c7 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 11:27:28 +0100 Subject: [PATCH 44/65] fix: add PTES enable validation in heat source utilisation profiles --- rules/build_sector.smk | 1 + scripts/build_heat_source_utilisation_profiles.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 25c4081756..4a9f208971 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -758,6 +758,7 @@ rule build_heat_source_utilisation_profiles: "geothermal", "constant_temperature_celsius", ), + ptes_enable=config_provider("sector", "district_heating", "ptes", "enable"), input: unpack(input_heat_source_temperature), unpack(input_ptes_bottom_temperature), diff --git a/scripts/build_heat_source_utilisation_profiles.py b/scripts/build_heat_source_utilisation_profiles.py index 267fb6161a..c37ab62692 100644 --- a/scripts/build_heat_source_utilisation_profiles.py +++ b/scripts/build_heat_source_utilisation_profiles.py @@ -246,6 +246,14 @@ def get_heat_pump_cooling( 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 From b36b832e850b33c619d7e3fb5dafa1bf04b04cdb Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 11:33:56 +0100 Subject: [PATCH 45/65] style: improve docstrings, move requires_heat_pump property upward --- scripts/definitions/heat_source.py | 82 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 460eab0f0a..1c4e2e8506 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -145,6 +145,33 @@ def requires_preheater(self) -> bool: 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. @@ -162,10 +189,13 @@ def get_capital_cost(self, costs, overdim_factor: float, heat_system) -> float: ------- 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. """ - # For direct utilisation heat sources, get cost from technology-data - # For other limited sources (like river_water without direct utilisation), return 0.0 - # For inexhaustible sources, this method shouldn't be called if self in [HeatSource.GEOTHERMAL]: return ( costs.at[ @@ -192,10 +222,13 @@ def get_lifetime(self, costs, heat_system) -> float: ------- 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. """ - # For direct utilisation heat sources, get lifetime from technology-data - # For other limited sources (like river_water without direct utilisation), return np.inf - # 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: @@ -226,13 +259,10 @@ def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: The bus2 name for the heat pump, or empty string if not applicable. """ if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: - # Inexhaustible sources (air, ground, sea-water) don't have a bus2 return "" elif self.requires_preheater: - # Sources with preheater use return-temperature bus return self.return_temperature_bus(nodes, heat_system) else: - # Limited sources without preheater use the heat carrier bus directly return self.resource_bus(nodes, heat_system) def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: @@ -243,6 +273,11 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: 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 @@ -279,39 +314,10 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: prepare_sector_network.add_heat : Creates heat pump links with this efficiency. """ if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: - # Inexhaustible sources don't need resource tracking return 1.0 else: - # Limited sources: efficiency2 = 1 - 1/COP for inverted link configuration return 1 - (1 / cop_heat_pump.clip(lower=0.001)) - 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 heat_carrier(self, heat_system) -> str: """ Get the carrier name for heat from this source. From 67ff7e48846cb3275602a533af85f68feeecabbd Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 11:42:22 +0100 Subject: [PATCH 46/65] fix: update parameter name from 'tes' to 'ttes' in docs --- doc/configtables/sector.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index 32c41a601a..eef60e48e3 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -116,7 +116,7 @@ retrofitting,,, -- annualise_cost,--,"{true, false}",Annualise the investment costs of retrofitting -- tax_weighting,--,"{true, false}",Weight the costs of retrofitting depending on taxes in countries -- construction_index,--,"{true, false}",Weight the costs of retrofitting depending on labour/material costs per country -tes,--,"{true, false}",Add option for storing thermal energy in large water pits associated with district heating systems and individual thermal energy storage (TES) +ttes,--,"{true, false}",Add option for storing thermal energy in large water pits associated with district heating systems and individual thermal energy storage (TES) boilers,--,"{true, false}",Add option for transforming gas into heat using gas boilers resistive_heaters,--,"{true, false}",Add option for transforming electricity into heat using resistive heaters (independently from gas boilers) oil_boilers,--,"{true, false}",Add option for transforming oil into heat using boilers From 820cbf249197f80b96723bf5aa5d840b1191adc5 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 12 Jan 2026 16:02:15 +0100 Subject: [PATCH 47/65] refactor: make preheating dynamic depending on temperature and rename medium/return temperature busses --- scripts/build_cop_profiles/run.py | 37 ++++++--------- scripts/definitions/heat_source.py | 72 ++++++++++++------------------ scripts/prepare_sector_network.py | 18 ++++---- 3 files changed, 51 insertions(+), 76 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index dc5046b66b..130cd38c70 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -183,7 +183,6 @@ def get_source_temperature( def get_source_inlet_temperature( - heat_source_name: str, source_temperature: float | xr.DataArray, central_heating_return_temperature: xr.DataArray, ) -> float | xr.DataArray: @@ -214,21 +213,18 @@ def get_source_inlet_temperature( float | xr.DataArray Effective source inlet temperature for the heat pump in Celsius. """ - heat_source = HeatSource(heat_source_name) - if heat_source.requires_preheater: - # 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 central_heating_return_temperature.where( - central_heating_return_temperature < source_temperature, source_temperature - ) - else: - return source_temperature + # 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( - heat_source_name: str, source_temperature: float | xr.DataArray, central_heating_return_temperature: xr.DataArray, central_heating_forward_temperature: xr.DataArray, @@ -261,14 +257,11 @@ def get_sink_inlet_temperature( float | xr.DataArray Effective sink inlet temperature for the heat pump in Celsius. """ - heat_source = HeatSource(heat_source_name) - if heat_source.requires_preheater: - return central_heating_forward_temperature.where( - central_heating_return_temperature < source_temperature, - central_heating_return_temperature, - ) - else: - return central_heating_return_temperature + return xr.where( + source_temperature > central_heating_return_temperature, + central_heating_forward_temperature, + central_heating_return_temperature, + ) if __name__ == "__main__": @@ -307,13 +300,11 @@ def get_sink_inlet_temperature( ) source_inlet_temperature_celsius = get_source_inlet_temperature( - heat_source_name=heat_source_name, source_temperature=source_temperature_celsius, central_heating_return_temperature=central_heating_return_temperature, ) sink_inlet_temperature_celsius = get_sink_inlet_temperature( - heat_source_name=heat_source_name, source_temperature=source_temperature_celsius, central_heating_forward_temperature=central_heating_forward_temperature, central_heating_return_temperature=central_heating_return_temperature, diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 1c4e2e8506..db75f827c0 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -126,25 +126,6 @@ def requires_generator(self) -> bool: else: return False - @property - def requires_preheater(self) -> bool: - """ - Check if the heat source uses preheating when below forward temperature. - - Preheating allows intermediate-temperature sources to warm the return - flow before a heat pump provides the final temperature lift, improving - overall system efficiency. - - Returns - ------- - bool - True for PTES and GEOTHERMAL, False otherwise. - """ - if self in [HeatSource.PTES, HeatSource.GEOTHERMAL]: - 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. @@ -234,7 +215,7 @@ def get_lifetime(self, costs, heat_system) -> float: else: return float("inf") - def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: + def get_heat_pump_input_bus(self, nodes, heat_system: str) -> str: """ Get the secondary input bus for the heat pump link. @@ -243,8 +224,7 @@ def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: based on the heat source type: - Inexhaustible sources: No bus2 (empty string) - - Sources with preheater: Return-temperature bus (post-preheat) - - Other limited sources: Resource bus directly + - Limited sources: bus2 is the heat pump input bus (at return temperature if pre-heated, else at source temperature) Parameters ---------- @@ -258,12 +238,10 @@ def get_heat_pump_bus2(self, nodes, heat_system: str) -> str: str The bus2 name for the heat pump, or empty string if not applicable. """ - if self in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: - return "" - elif self.requires_preheater: - return self.return_temperature_bus(nodes, heat_system) + if self.requires_bus: + return self.heat_pump_input_bus(nodes, heat_system) else: - return self.resource_bus(nodes, heat_system) + return "" def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: """ @@ -306,17 +284,23 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: Returns ------- float or pd.Series - 1.0 for inexhaustible sources (no resource tracking needed), 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 in [HeatSource.AIR, HeatSource.GROUND, HeatSource.SEA_WATER]: - return 1.0 - else: + if self.requires_bus: return 1 - (1 / cop_heat_pump.clip(lower=0.001)) + else: + raise NotImplementedError( + "get_heat_pump_efficiency2 should not be called for inexhaustible heat sources, since they don't have a bus2." + ) def heat_carrier(self, heat_system) -> str: """ @@ -335,7 +319,7 @@ def heat_carrier(self, heat_system) -> str: """ return f"{heat_system} {self} heat" - def medium_temperature_carrier(self, heat_system) -> str: + def preheater_input_carrier(self, heat_system) -> str: """ Get the carrier name for partially-cooled heat from this source. @@ -350,15 +334,15 @@ def medium_temperature_carrier(self, heat_system) -> str: Returns ------- str - Carrier name with '-medium-temperature' suffix in format '{heat_system} {source} medium-temperature'. + Carrier name with '-pre-heater input' suffix in format '{heat_system} {source} pre-heater input'. """ - return f"{self.heat_carrier(heat_system)} medium-temperature" + return f"{self.heat_carrier(heat_system)} pre-heater input" - def return_temperature_carrier(self, heat_system) -> str: + def heat_pump_input_carrier(self, heat_system) -> str: """ Get the carrier name for fully-cooled heat from this source. - Represents heat at return temperature after preheating, ready for + Represents heat pump input, for final temperature lift by heat pump. Parameters @@ -369,11 +353,11 @@ def return_temperature_carrier(self, heat_system) -> str: Returns ------- str - Carrier name with '-return-temperature' suffix in format '{heat_system} {source} return-temperature'. + Carrier name with '-heat-pump input' suffix in format '{heat_system} {source} heat-pump input'. """ - return f"{self.heat_carrier(heat_system)} return-temperature" + return f"{self.heat_carrier(heat_system)} heat-pump input" - def medium_temperature_bus(self, nodes, heat_system) -> str: + def preheater_input_bus(self, nodes, heat_system) -> str: """ Get bus name for partially-cooled heat at the given nodes. @@ -387,11 +371,11 @@ def medium_temperature_bus(self, nodes, heat_system) -> str: Returns ------- str - Bus name combining nodes with medium-temperature carrier in format 'nodes + {heat_system} {source} medium-temperature'. + Bus name combining nodes with medium-temperature carrier in format 'nodes + {heat_system} {source} pre-heater input'. """ - return nodes + f" {self.medium_temperature_carrier(heat_system)}" + return nodes + f" {self.preheater_input_carrier(heat_system)}" - def return_temperature_bus(self, nodes, heat_system) -> str: + def heat_pump_input_bus(self, nodes, heat_system) -> str: """ Get bus name for fully-cooled heat at the given nodes. @@ -405,9 +389,9 @@ def return_temperature_bus(self, nodes, heat_system) -> str: Returns ------- str - Bus name combining nodes with return-temperature carrier in format 'nodes + {heat_system} {source} return-temperature'. + Bus name combining nodes with heat-pump input carrier in format 'nodes + {heat_system} {source} heat-pump input'. """ - return nodes + f" {self.return_temperature_carrier(heat_system)}" + return nodes + f" {self.heat_pump_input_carrier(heat_system)}" def resource_bus(self, nodes, heat_system) -> str: """ diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d9ff488c4b..7d967e3e59 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3295,25 +3295,25 @@ def add_heat( n.add( "Bus", - heat_source.medium_temperature_bus(nodes, heat_system), + heat_source.preheater_input_bus(nodes, heat_system), location=nodes, - carrier=heat_source.medium_temperature_carrier(heat_system), + carrier=heat_source.preheater_input_carrier(heat_system), ) n.add( "Bus", - heat_source.return_temperature_bus(nodes, heat_system), + heat_source.heat_pump_input_bus(nodes, heat_system), location=nodes, - carrier=heat_source.return_temperature_carrier(heat_system), + carrier=heat_source.heat_pump_input_carrier(heat_system), ) n.add( "Link", nodes, suffix=f" {heat_system} {heat_source} heat preheater", - bus0=heat_source.medium_temperature_bus(nodes, heat_system), + bus0=heat_source.preheater_input_bus(nodes, heat_system), bus1=nodes + f" {heat_system} heat", - bus2=heat_source.return_temperature_bus(nodes, heat_system), + bus2=heat_source.heat_pump_input_bus(nodes, heat_system), efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, p_max_pu=(preheater_utilisation_profile > 0).astype(float), @@ -3337,7 +3337,7 @@ def add_heat( 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.medium_temperature_bus(nodes, heat_system), + 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", @@ -3376,7 +3376,7 @@ def add_heat( p_max_pu=p_max_pu, ) - bus2_heat_pump = heat_source.get_heat_pump_bus2(nodes, heat_system) + bus2_heat_pump = heat_source.get_heat_pump_input_bus(nodes, heat_system) efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) if heat_source.requires_heat_pump( @@ -3434,7 +3434,7 @@ def add_heat( suffix=f" {heat_system} water pits resistive booster", bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} resistive heat", - bus2=ptes_heat_source.medium_temperature_bus(nodes, heat_system), + 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), From fd9441281eaea608e109240073996d717a3fcd03 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 14 Jan 2026 10:29:31 +0100 Subject: [PATCH 48/65] fix: disable discharge resistive boosting in default config --- config/config.default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index d2363b3ea3..ec320132fc 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -529,7 +529,7 @@ sector: enable: true temperature_dependent_capacity: false charge_boosting_required: false - discharge_resistive_boosting: true + discharge_resistive_boosting: false top_temperature: 90 bottom_temperature: 35 design_top_temperature: 90 From f83a3fa9b7fa489b8f4d7034d899cf566517f81d Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 14 Jan 2026 10:29:43 +0100 Subject: [PATCH 49/65] fix: handle inexhaustible heat sources in get_heat_pump_efficiency2 method --- scripts/definitions/heat_source.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index db75f827c0..533ae05489 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -298,9 +298,7 @@ def get_heat_pump_efficiency2(self, cop_heat_pump) -> float: if self.requires_bus: return 1 - (1 / cop_heat_pump.clip(lower=0.001)) else: - raise NotImplementedError( - "get_heat_pump_efficiency2 should not be called for inexhaustible heat sources, since they don't have a bus2." - ) + return None def heat_carrier(self, heat_system) -> str: """ From 26f62169f9451d8901db1aaaf25a3fde686cd3cd Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 14 Jan 2026 10:29:54 +0100 Subject: [PATCH 50/65] fix: transpose heat data in add_heat function for correct indexing --- scripts/prepare_sector_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 7d967e3e59..a03272d86b 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3290,7 +3290,7 @@ def add_heat( heat_source=heat_source.value, name=nodes ) .to_pandas() - .reindex(index=n.snapshots) + .T.reindex(index=n.snapshots) ) n.add( @@ -3326,7 +3326,7 @@ def add_heat( heat_source=heat_source.value, name=nodes ) .to_pandas() - .reindex(index=n.snapshots) + .T.reindex(index=n.snapshots) ) # add link for direct usage of heat source when source temperature exceeds forward temperature From 7ab78f104e68cc3ceeba659a09f19ce5c50557f9 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 14 Jan 2026 16:28:43 +0100 Subject: [PATCH 51/65] fix incorrect preheater power limits and clean up obsolete method calls --- scripts/definitions/heat_source.py | 20 +------------------- scripts/prepare_sector_network.py | 9 ++------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 533ae05489..4978f94615 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -239,7 +239,7 @@ def get_heat_pump_input_bus(self, nodes, heat_system: str) -> str: The bus2 name for the heat pump, or empty string if not applicable. """ if self.requires_bus: - return self.heat_pump_input_bus(nodes, heat_system) + return nodes + f" {self.heat_pump_input_carrier(heat_system)}" else: return "" @@ -373,24 +373,6 @@ def preheater_input_bus(self, nodes, heat_system) -> str: """ return nodes + f" {self.preheater_input_carrier(heat_system)}" - def heat_pump_input_bus(self, nodes, heat_system) -> str: - """ - Get bus name for fully-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 heat-pump input carrier in format 'nodes + {heat_system} {source} heat-pump input'. - """ - return nodes + f" {self.heat_pump_input_carrier(heat_system)}" - def resource_bus(self, nodes, heat_system) -> str: """ Get the primary resource bus for heat from this source. diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index a03272d86b..e6f7f4a57a 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3316,7 +3316,6 @@ def add_heat( bus2=heat_source.heat_pump_input_bus(nodes, heat_system), efficiency=preheater_utilisation_profile, efficiency2=1 - preheater_utilisation_profile, - p_max_pu=(preheater_utilisation_profile > 0).astype(float), carrier=f"{heat_system} {heat_source} heat preheater", p_nom_extendable=True, ) @@ -3376,9 +3375,6 @@ def add_heat( p_max_pu=p_max_pu, ) - bus2_heat_pump = heat_source.get_heat_pump_input_bus(nodes, heat_system) - efficiency2_heat_pump = heat_source.get_heat_pump_efficiency2(cop_heat_pump) - if heat_source.requires_heat_pump( ptes_discharge_resistive_boosting=params.sector["district_heating"][ "ptes" @@ -3390,14 +3386,13 @@ def add_heat( suffix=f" {heat_system} {heat_source} heat pump", bus0=nodes + f" {heat_system} heat", bus1=nodes, - bus2=bus2_heat_pump, + bus2=heat_source.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=efficiency2_heat_pump, + efficiency2=heat_source.get_heat_pump_efficiency2(cop_heat_pump), capital_cost=costs.at[costs_name_heat_pump, "capital_cost"] * overdim_factor, 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"], ) From ec33f1bf6386bb16895da237135a3ec2394fa661 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 14 Jan 2026 18:43:00 +0100 Subject: [PATCH 52/65] fix: update heat pump input bus method calls for consistency --- scripts/prepare_sector_network.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index e6f7f4a57a..b324c88331 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3302,7 +3302,7 @@ def add_heat( n.add( "Bus", - heat_source.heat_pump_input_bus(nodes, heat_system), + heat_source.get_heat_pump_input_bus(nodes, heat_system), location=nodes, carrier=heat_source.heat_pump_input_carrier(heat_system), ) @@ -3313,7 +3313,7 @@ def add_heat( 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.heat_pump_input_bus(nodes, heat_system), + 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", @@ -3386,7 +3386,7 @@ def add_heat( suffix=f" {heat_system} {heat_source} heat pump", bus0=nodes + f" {heat_system} heat", bus1=nodes, - bus2=heat_source.heat_pump_input_bus(nodes, heat_system), + 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=heat_source.get_heat_pump_efficiency2(cop_heat_pump), From cce3f2f8be54d38f585ceb7427e6d04759c2a5a1 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 19 Jan 2026 14:18:10 +0100 Subject: [PATCH 53/65] update config and validation --- config/config.default.yaml | 10 -- scripts/lib/validation/config/sector.py | 183 +++++++++++++++++++----- 2 files changed, 147 insertions(+), 46 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 726ba2f5a4..7be3a74d65 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -677,16 +677,6 @@ 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 diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 1c554073b4..f3481bfa81 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( + # Allowed heat sources per system type (used for validation and description) + _allowed_heat_sources: dict[HeatSystemType, list[HeatSource]] = { + HeatSystemType.URBAN_CENTRAL: [s for s in HeatSource if s != HeatSource.GROUND], + HeatSystemType.URBAN_DECENTRAL: [HeatSource.AIR], + HeatSystemType.RURAL: [HeatSource.AIR, HeatSource.GROUND], + } + + 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: " + + "; ".join( + f"{st.value}: {[s.value for s in sources]}" + for st, sources in _allowed_heat_sources.items() + ) + + ".", + ) + + @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.""" + for system_type, sources in v.items(): + allowed = SectorConfig._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, @@ -631,9 +742,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." From 4b639b342afb9f2425594640a478a28047f92c84 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 19 Jan 2026 14:48:26 +0100 Subject: [PATCH 54/65] refactor: simplify heat sources validation in SectorConfig --- scripts/lib/validation/config/sector.py | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index f3481bfa81..b69dfd2b2f 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -461,13 +461,6 @@ class SectorConfig(BaseModel): description="District heating configuration.", ) - # Allowed heat sources per system type (used for validation and description) - _allowed_heat_sources: dict[HeatSystemType, list[HeatSource]] = { - HeatSystemType.URBAN_CENTRAL: [s for s in HeatSource if s != HeatSource.GROUND], - HeatSystemType.URBAN_DECENTRAL: [HeatSource.AIR], - HeatSystemType.RURAL: [HeatSource.AIR, HeatSource.GROUND], - } - heat_sources: dict[HeatSystemType, list[HeatSource]] = Field( default_factory=lambda: { HeatSystemType.URBAN_CENTRAL: [ @@ -478,12 +471,12 @@ class SectorConfig(BaseModel): HeatSystemType.URBAN_DECENTRAL: [HeatSource.AIR], HeatSystemType.RURAL: [HeatSource.AIR, HeatSource.GROUND], }, - description="Heat sources by heat system type. Allowed: " - + "; ".join( - f"{st.value}: {[s.value for s in sources]}" - for st, sources in _allowed_heat_sources.items() - ) - + ".", + description=( + "Heat sources by heat system type. Allowed: " + "urban central: all except 'ground'; " + "urban decentral: ['air']; " + "rural: ['air', 'ground']." + ), ) @field_validator("heat_sources") @@ -492,8 +485,15 @@ 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 = SectorConfig._allowed_heat_sources[system_type] + allowed = allowed_heat_sources[system_type] invalid = [s for s in sources if s not in allowed] if invalid: raise ValueError( From 9ec39f0b283d8cef061040f6956d6f6dba85b8f5 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Mon, 2 Feb 2026 16:15:01 +0100 Subject: [PATCH 55/65] fix: remove unnecessary transpose in heat source data reindexing --- scripts/prepare_sector_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b324c88331..a7da10b3e5 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3290,7 +3290,7 @@ def add_heat( heat_source=heat_source.value, name=nodes ) .to_pandas() - .T.reindex(index=n.snapshots) + .reindex(index=n.snapshots) ) n.add( @@ -3325,7 +3325,7 @@ def add_heat( heat_source=heat_source.value, name=nodes ) .to_pandas() - .T.reindex(index=n.snapshots) + .reindex(index=n.snapshots) ) # add link for direct usage of heat source when source temperature exceeds forward temperature From 1fc198916467db08f6e667709c79dd4bf3d1829d Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Tue, 3 Feb 2026 13:57:29 +0100 Subject: [PATCH 56/65] fix: add ptes configuration for district heating in add_brownfield rule --- rules/solve_myopic.smk | 1 + 1 file changed, 1 insertion(+) diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 9e32888f39..9f9527ab26 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -73,6 +73,7 @@ rule add_brownfield: carriers=config_provider("electricity", "renewable_carriers"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), ttes=config_provider("sector", "ttes"), + ptes=config_provider("sector", "district_heating", "ptes"), dynamic_ptes_capacity=config_provider( "sector", "district_heating", "ptes", "dynamic_capacity" ), From 2026e40162c9072eeb971248f36978d9e2d067a0 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 4 Feb 2026 17:06:01 +0100 Subject: [PATCH 57/65] refactor: remove debugging e_nom_min for PTES --- scripts/prepare_sector_network.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b324c88331..0fe6612dd6 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3197,7 +3197,6 @@ def add_heat( / 100, # convert %/hour into unit/hour capital_cost=costs.at["central water pit storage", "capital_cost"], lifetime=costs.at["central water pit storage", "lifetime"], - e_nom_min=100000, ) if enable_ates and heat_system == HeatSystem.URBAN_CENTRAL: From 16115e9f02c423e756397297d1abf990baf0cbdf Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 4 Feb 2026 17:07:39 +0100 Subject: [PATCH 58/65] fix incrrect sink-inlet temperature for COP approximation --- scripts/build_cop_profiles/run.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index 130cd38c70..84d8cbd189 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -227,7 +227,6 @@ def get_source_inlet_temperature( def get_sink_inlet_temperature( source_temperature: float | xr.DataArray, central_heating_return_temperature: xr.DataArray, - central_heating_forward_temperature: xr.DataArray, ) -> float | xr.DataArray: """ Determine the effective sink inlet temperature for the heat pump. @@ -238,8 +237,8 @@ def get_sink_inlet_temperature( 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 forward temp. - When source temperature <= return temperature, no preheating: heat pump inlet is at return temperature. + When source temperature > return temperature, preheater is used: preheater raises return flow, heat pump inlet is at return temp. + When source temperature <= return temperature, no preheating: heat pump inlet is at source temperature. Parameters ---------- @@ -249,8 +248,6 @@ def get_sink_inlet_temperature( Temperature of the heat source in Celsius. central_heating_return_temperature : xr.DataArray District heating return temperature in Celsius. - central_heating_forward_temperature : xr.DataArray - District heating forward (supply) temperature in Celsius. Returns ------- @@ -259,8 +256,8 @@ def get_sink_inlet_temperature( """ return xr.where( source_temperature > central_heating_return_temperature, - central_heating_forward_temperature, central_heating_return_temperature, + source_temperature, ) From 2904c8e54db9a99d24f1b12dd05da3080a782fd0 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Wed, 4 Feb 2026 17:15:55 +0100 Subject: [PATCH 59/65] refactor: discard error handling in get_source_temperature function --- scripts/build_cop_profiles/run.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index 84d8cbd189..ddd993c9a5 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -167,18 +167,8 @@ def get_source_temperature( """ 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." - ) - + return snakemake_params[f"constant_temperature_{heat_source_name}"] 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}"]) From a32fe3463c62384fc511d7e5c2750826d31653db Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 5 Feb 2026 10:43:53 +0100 Subject: [PATCH 60/65] docs: clarify scaling conditions for geothermal heat potentials --- scripts/build_geothermal_heat_potential.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/build_geothermal_heat_potential.py b/scripts/build_geothermal_heat_potential.py index 4522d77e93..1dbafa1d66 100644 --- a/scripts/build_geothermal_heat_potential.py +++ b/scripts/build_geothermal_heat_potential.py @@ -15,13 +15,13 @@ 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): +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): +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): +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 From afcec93326128128ef9ad8786025808f16773151 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 5 Feb 2026 10:46:34 +0100 Subject: [PATCH 61/65] docs: enhance docstring for resource_bus method in HeatSource class --- scripts/definitions/heat_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/definitions/heat_source.py b/scripts/definitions/heat_source.py index 4978f94615..26f1a74399 100644 --- a/scripts/definitions/heat_source.py +++ b/scripts/definitions/heat_source.py @@ -375,7 +375,7 @@ def preheater_input_bus(self, nodes, heat_system) -> str: def resource_bus(self, nodes, heat_system) -> str: """ - Get the primary resource bus for heat from this source. + 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. From b2befe122d15c11f9e28633f72d0e295ee24c9a9 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 5 Feb 2026 10:50:29 +0100 Subject: [PATCH 62/65] refactor: update PTES resource bus creation in add_heat function --- scripts/prepare_sector_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 11e7af2939..b2fe544b2b 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3165,7 +3165,7 @@ def add_heat( n.add( "Bus", - nodes + f" {heat_system} ptes heat", + HeatSource.PTES.resource_bus(nodes, heat_system), location=nodes, carrier=f"{heat_system} ptes heat", unit="MWh_th", From 3afee8e50df19c521fc24a8907f68eee0fe6da23 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Thu, 5 Feb 2026 10:54:44 +0100 Subject: [PATCH 63/65] refactor: correct logic in get_sink_inlet_temperature function documentation and implementation --- scripts/build_cop_profiles/run.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/build_cop_profiles/run.py b/scripts/build_cop_profiles/run.py index ddd993c9a5..cd4b745614 100644 --- a/scripts/build_cop_profiles/run.py +++ b/scripts/build_cop_profiles/run.py @@ -227,8 +227,8 @@ def get_sink_inlet_temperature( 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 return temp. - When source temperature <= return temperature, no preheating: heat pump inlet is at source 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 ---------- @@ -246,8 +246,8 @@ def get_sink_inlet_temperature( """ return xr.where( source_temperature > central_heating_return_temperature, - central_heating_return_temperature, source_temperature, + central_heating_return_temperature, ) @@ -293,7 +293,6 @@ def get_sink_inlet_temperature( sink_inlet_temperature_celsius = get_sink_inlet_temperature( source_temperature=source_temperature_celsius, - central_heating_forward_temperature=central_heating_forward_temperature, central_heating_return_temperature=central_heating_return_temperature, ) From e9d99a4b8ffe417738f12dc585fdb1203264803d Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Fri, 6 Feb 2026 13:57:44 +0100 Subject: [PATCH 64/65] fix: handle dataarray conversion of heating technologies more robustly --- scripts/prepare_sector_network.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 97de4775b5..b128324841 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -3244,8 +3244,8 @@ def add_heat( 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"]] ) @@ -3266,8 +3266,8 @@ def add_heat( heat_source_preheater_utilisation_profile.sel( heat_source=heat_source.value, name=nodes ) + .transpose("time", "name") .to_pandas() - .reindex(index=n.snapshots) ) n.add( @@ -3301,8 +3301,8 @@ def add_heat( heat_source_direct_utilisation_profile.sel( heat_source=heat_source.value, name=nodes ) + .transpose("time", "name") .to_pandas() - .reindex(index=n.snapshots) ) # add link for direct usage of heat source when source temperature exceeds forward temperature @@ -3370,6 +3370,7 @@ def add_heat( capital_cost=costs.at[costs_name_heat_pump, "capital_cost"] * overdim_factor, 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"], ) From a54e6c7a5084a0884a4adfa41621e8a95748a3c7 Mon Sep 17 00:00:00 2001 From: Amos Schledorn Date: Fri, 6 Feb 2026 13:58:08 +0100 Subject: [PATCH 65/65] fix: improve error handling in logarithmic_mean method for temperature calculations --- scripts/build_cop_profiles/base_cop_approximator.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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), + ), )