Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
44ea1cd
feat: first draft of layered PTES model
amos-schledorn Mar 11, 2026
b6727d0
refactor: remove expand_ptes_layers function and related validation f…
amos-schledorn Mar 12, 2026
9bc232e
feat: add layered PTES heat pump capacity constraint function
amos-schledorn Mar 12, 2026
1d2d5d8
introduce HP boosting and cost ptes discharging
amos-schledorn Mar 12, 2026
30fc522
fix volume capacity weights
amos-schledorn Mar 12, 2026
ee02599
Merge tag 'v2026.02.0' into refactor-ptes-boosting
amos-schledorn Mar 20, 2026
bf30c43
use conducive heat transfer for interlayer transfer
amos-schledorn Mar 26, 2026
12ce641
fix: update marginal cost for central water pit charger
amos-schledorn Mar 26, 2026
d6c377a
feat: add additional PTES layers to heat source enumeration
amos-schledorn Mar 26, 2026
1cf12d9
first draft: simplify boosting
amos-schledorn Apr 1, 2026
c253e0d
feat: add interlayer heat transfer coefficient to PTES operations
amos-schledorn Apr 7, 2026
1645334
Merge tag 'v2026.02.0' into volume-layers
amos-schledorn Apr 7, 2026
ad5f1de
update boosting logic
amos-schledorn Apr 7, 2026
c416824
fix boosting logic:
amos-schledorn Apr 8, 2026
d52a69f
fix faulty HP input for inexhaustible heat sources
amos-schledorn Apr 8, 2026
fd178c2
clean up docstrings
amos-schledorn Apr 8, 2026
02b66f1
fix util link ratios
amos-schledorn Apr 13, 2026
a48ec40
working draft
amos-schledorn Apr 21, 2026
e0f2051
fix efficiencies
amos-schledorn Apr 28, 2026
ddad176
fix incorrekt HP sink inlet temperature
amos-schledorn Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,9 @@ sector:
bottom_temperature: 35
design_top_temperature: 90
design_bottom_temperature: 35
layered:
num_layers: 1
heat_transfer_coefficient: 0.01 # H [W/(m²·K)]
ates:
enable: false
suitable_aquifer_types:
Expand Down
3 changes: 2 additions & 1 deletion data/custom_costs.csv
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ all,battery,marginal_cost,0,EUR/MWh,Default value to prevent mathematical degene
all,battery inverter,marginal_cost,0,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
all,home battery storage,marginal_cost,0,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
all,water tank charger,marginal_cost,0.03,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
all,central water pit charger,marginal_cost,0.025,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
all,central water pit charger,marginal_cost,0.04,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
all,central water pit discharger,marginal_cost,0.05,EUR/MWh,Default value to prevent mathematical degeneracy in optimisation,
66 changes: 19 additions & 47 deletions rules/build_sector.smk
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,6 @@ def input_heat_source_temperature(
replace_names: dict[str, str] = {
"air": "air_total",
"ground": "soil_total",
"ptes": "ptes_top_profiles",
},
) -> dict[str, str]:
"""
Expand All @@ -549,7 +548,7 @@ def input_heat_source_temperature(
for heat sources that require temperature profiles (excludes constant
temperature sources).
"""
from scripts.definitions.heat_source import HeatSource
from scripts.definitions.heat_source import HeatSource, HeatSourceType

heat_sources = set(
config_provider("sector", "heat_sources", "urban central")(w)
Expand All @@ -562,25 +561,23 @@ def input_heat_source_temperature(
for heat_source_name in heat_sources:
heat_source = HeatSource(heat_source_name)
# Skip heat sources with temperatures defined in config (not from file)
if heat_source.temperature_from_config:
if (
heat_source.temperature_from_config
or heat_source.source_type == HeatSourceType.STORAGE
):
continue
if heat_source_name == "ptes":
file_names[f"temp_{heat_source_name}"] = resources(
f"temp_{replace_names.get(heat_source_name, heat_source_name)}_base_s_{{clusters}}_{{planning_horizons}}.nc"
)
else:
file_names[f"temp_{heat_source_name}"] = resources(
f"temp_{replace_names.get(heat_source_name, heat_source_name)}_base_s_{{clusters}}.nc"
)
return file_names


def input_ptes_bottom_temperature(w) -> dict[str, str]:
def input_ptes_operations(w) -> dict[str, str]:
"""
Generate conditional input for PTES bottom temperature profiles.
Generate conditional input for PTES operations.

Only includes the input file if PTES is configured as a heat source
for urban central heating.
Only includes the input file if PTES is enabled.

Parameters
----------
Expand All @@ -593,11 +590,10 @@ def input_ptes_bottom_temperature(w) -> 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:
if config_provider("sector", "district_heating", "ptes", "enable")(w):
return {
"temp_ptes_bottom": resources(
"temp_ptes_bottom_profiles_base_s_{clusters}_{planning_horizons}.nc"
"ptes_operations": resources(
"ptes_operations_base_s_{clusters}_{planning_horizons}.nc"
)
}
return {}
Expand Down Expand Up @@ -678,9 +674,12 @@ rule build_cop_profiles:
heat_source_temperatures=config_provider(
"sector", "district_heating", "heat_source_temperatures"
),
ptes_enable=config_provider("sector", "district_heating", "ptes", "enable"),
ptes_layered=config_provider("sector", "district_heating", "ptes", "layered"),
snapshots=config_provider("snapshots"),
input:
unpack(input_heat_source_temperature),
unpack(input_ptes_operations),
central_heating_forward_temperature_profiles=resources(
"central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
Expand Down Expand Up @@ -738,6 +737,7 @@ rule build_ptes_operations:
"ptes",
"design_bottom_temperature",
),
layered=config_provider("sector", "district_heating", "ptes", "layered"),
input:
central_heating_forward_temperature_profiles=resources(
"central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc"
Expand All @@ -747,17 +747,8 @@ rule build_ptes_operations:
),
regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"),
output:
ptes_top_temperature_profiles=resources(
"temp_ptes_top_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
ptes_bottom_temperature_profiles=resources(
"temp_ptes_bottom_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
ptes_e_max_pu_profiles=resources(
"ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
ptes_boost_per_discharge_profiles=resources(
"ptes_boost_per_discharge_profiles_base_s_{clusters}_{planning_horizons}.nc"
ptes_operations=resources(
"ptes_operations_base_s_{clusters}_{planning_horizons}.nc"
),
resources:
mem_mb=2000,
Expand All @@ -784,7 +775,7 @@ rule build_heat_source_utilisation_profiles:
ptes_enable=config_provider("sector", "district_heating", "ptes", "enable"),
input:
unpack(input_heat_source_temperature),
unpack(input_ptes_bottom_temperature),
unpack(input_ptes_operations),
central_heating_forward_temperature_profiles=resources(
"central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
Expand Down Expand Up @@ -1620,6 +1611,7 @@ rule prepare_sector_network:
input:
unpack(input_profile_offwind),
unpack(input_heat_source_power),
unpack(input_ptes_operations),
**rules.cluster_gas_network.output,
**rules.build_gas_input_locations.output,
snapshot_weightings=resources(
Expand Down Expand Up @@ -1692,26 +1684,6 @@ rule prepare_sector_network:
temp_soil_total=resources("temp_soil_total_base_s_{clusters}.nc"),
temp_air_total=resources("temp_air_total_base_s_{clusters}.nc"),
cop_profiles=resources("cop_profiles_base_s_{clusters}_{planning_horizons}.nc"),
ptes_e_max_pu_profiles=lambda w: (
resources(
"ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc"
)
if config_provider("sector", "district_heating", "ptes", "enable")(w)
and config_provider(
"sector", "district_heating", "ptes", "temperature_dependent_capacity"
)(w)
else []
),
ptes_boost_per_discharge_profiles=lambda w: (
resources(
"ptes_boost_per_discharge_profiles_base_s_{clusters}_{planning_horizons}.nc"
)
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"
Expand Down
1 change: 1 addition & 0 deletions rules/solve_myopic.smk
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ rule solve_sector_network_myopic:
),
custom_extra_functionality=input_custom_extra_functionality,
input:
unpack(input_ptes_operations),
network=resources(
"networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_brownfield.nc"
),
Expand Down
1 change: 1 addition & 0 deletions rules/solve_overnight.smk
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ rule solve_sector_network:
),
custom_extra_functionality=input_custom_extra_functionality,
input:
unpack(input_ptes_operations),
network=resources(
"networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc"
),
Expand Down
1 change: 1 addition & 0 deletions rules/solve_perfect.smk
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ rule solve_sector_network_perfect:
),
custom_extra_functionality=input_custom_extra_functionality,
input:
unpack(input_ptes_operations),
network=resources(
"networks/base_s_{clusters}_{opts}_{sector_opts}_brownfield_all_years.nc"
),
Expand Down
20 changes: 19 additions & 1 deletion scripts/build_cop_profiles/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
from scripts.build_cop_profiles.decentral_heating_cop_approximator import (
DecentralHeatingCopApproximator,
)
from scripts.definitions.heat_source import HeatSource
from scripts.definitions.heat_source import HeatSource, HeatSourceType
from scripts.definitions.heat_system_type import HeatSystemType


Expand Down Expand Up @@ -167,6 +167,13 @@ def get_source_temperature(
heat_source = HeatSource(heat_source_name)
if heat_source.temperature_from_config:
return snakemake_params["heat_source_temperatures"][heat_source_name]
elif heat_source.source_type == HeatSourceType.STORAGE:
# PTES layer temperatures are constants from the ptes_operations dataset
if heat_source_name.startswith("ptes layer"):
layer_idx = int(heat_source_name.split()[-1])
return float(ptes_ds["layer_temperatures"].sel(layer=layer_idx).item())
else:
return float(ptes_ds.attrs["top_temperature"])
else:
return xr.open_dataarray(snakemake_input[f"temp_{heat_source_name}"])

Expand Down Expand Up @@ -268,6 +275,14 @@ def get_sink_inlet_temperature(
snakemake.input.central_heating_return_temperature_profiles
)

# Load PTES operations dataset if enabled
if snakemake.params.ptes_enable:
ptes_ds = xr.open_dataset(snakemake.input.ptes_operations)
num_ptes_layers = int(ptes_ds.attrs["num_layers"])
else:
ptes_ds = None
num_ptes_layers = 0

cop_all_system_types = []
for heat_system_type, heat_sources in snakemake.params.heat_sources.items():
cop_this_system_type = []
Expand Down Expand Up @@ -309,6 +324,9 @@ def get_sink_inlet_temperature(
)
)

if ptes_ds is not None:
ptes_ds.close()

cop_dataarray = xr.concat(
cop_all_system_types,
dim=pd.Index(snakemake.params.heat_sources.keys(), name="heat_system"),
Expand Down
54 changes: 26 additions & 28 deletions scripts/build_heat_source_utilisation_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
import xarray as xr

from scripts._helpers import configure_logging, set_scenario_config
from scripts.definitions.heat_source import HeatSource
from scripts.definitions.heat_source import HeatSource, HeatSourceType

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -96,6 +96,13 @@ def get_source_temperature(
heat_source = HeatSource(heat_source_name)
if heat_source.temperature_from_config:
return snakemake_params["heat_source_temperatures"][heat_source_name]
elif heat_source.source_type == HeatSourceType.STORAGE:
# PTES layer temperatures are constants from the ptes_operations dataset
if heat_source_name.startswith("ptes layer"):
layer_idx = int(heat_source_name.split()[-1])
return float(ptes_ds["layer_temperatures"].sel(layer=layer_idx).item())
else:
return float(ptes_ds.attrs["top_temperature"])
else:
if f"temp_{heat_source_name}" not in snakemake_input.keys():
raise ValueError(
Expand Down Expand Up @@ -182,50 +189,38 @@ def get_preheater_utilisation_profile(
def get_heat_pump_cooling(
heat_source_name: str,
default_heat_source_cooling: float,
snakemake_input: dict,
return_temperature: xr.DataArray = None,
) -> float | xr.DataArray:
"""
Get the additional heat source cooling (temperature drop) through the heat pump for a heat source.

For PTES, this equals the temperature difference between
return flow and bottom layers (return_temperature - bottom_temperature), which can
be time-varying. For other sources, uses the default constant value.
return flow and bottom layer (return_temperature - bottom_temperature).
For other sources, uses the default constant value.

Parameters
----------
heat_source_name : str
Name of the heat source (e.g., 'ptes', 'geothermal', 'air').
default_heat_source_cooling : float
Default heat source cooling in Kelvin, from config.
snakemake_input : dict
Snakemake input files, may contain PTES temperature profiles.
return_temperature : xr.DataArray, optional
District heating return temperature profiles in °C. Required for PTES.

Returns
-------
float | xr.DataArray
Heat source cooling in Kelvin. Returns a float for most sources,
or a DataArray for PTES when temperatures vary with time.

Raises
------
ValueError
If heat source is PTES but bottom temperature profile is not provided.
or a DataArray for PTES.
"""
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."
)
heat_source = HeatSource(heat_source_name)
if heat_source.source_type == HeatSourceType.STORAGE:
if return_temperature is None:
raise ValueError(
"PTES heat source requires return_temperature to calculate heat pump cooling."
)

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message in get_heat_pump_cooling says "PTES heat source" but the condition is now heat_source.source_type == STORAGE, so it will also trigger for ptes layer *. Consider making the message generic (e.g. "Storage heat source requires return_temperature...") to avoid confusion when debugging layered PTES configs.

Copilot uses AI. Check for mistakes.
ptes_bottom_temperature = xr.open_dataarray(snakemake_input["temp_ptes_bottom"])
return return_temperature - ptes_bottom_temperature
bottom_temperature = float(ptes_ds.attrs["bottom_temperature"])
return return_temperature - bottom_temperature
return default_heat_source_cooling


Expand All @@ -234,7 +229,7 @@ def get_heat_pump_cooling(
from scripts._helpers import mock_snakemake

snakemake = mock_snakemake(
"build_cop_profiles",
"build_heat_source_utilisation_profiles",
clusters=48,
)
configure_logging(snakemake)
Expand All @@ -243,12 +238,13 @@ def get_heat_pump_cooling(
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."
)
# Load PTES operations dataset if enabled
if ptes_enable:
ptes_ds = xr.open_dataset(snakemake.input.ptes_operations)
num_ptes_layers = int(ptes_ds.attrs["num_layers"])
else:
ptes_ds = None
num_ptes_layers = 0

central_heating_forward_temperature: xr.DataArray = xr.open_dataarray(
snakemake.input.central_heating_forward_temperature_profiles
Expand Down Expand Up @@ -285,11 +281,13 @@ def get_heat_pump_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
],
dim="heat_source",
).to_netcdf(snakemake.output.heat_source_preheater_utilisation_profiles)

if ptes_ds is not None:
ptes_ds.close()
Loading
Loading