Skip to content

feat: layered ptes model#2129

Open
amos-schledorn wants to merge 8 commits intolake-heat-pumpsfrom
feat-layered-ptes-model
Open

feat: layered ptes model#2129
amos-schledorn wants to merge 8 commits intolake-heat-pumpsfrom
feat-layered-ptes-model

Conversation

@amos-schledorn
Copy link
Copy Markdown
Contributor

Closes # (if applicable).

Changes proposed in this Pull Request

Checklist

Required:

  • Changes are tested locally and behave as expected.
  • Code and workflow changes are documented.
  • A release note entry is added to doc/release_notes.rst.

If applicable:

  • Changes in configuration options are reflected in scripts/lib/validation.
  • For new data sources or versions, these instructions have been followed.
  • New rules are documented in the appropriate doc/*.rst files.

Copilot AI review requested due to automatic review settings March 26, 2026 09:33
@amos-schledorn amos-schledorn changed the title Feat layered ptes model feat: layered ptes model Mar 26, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a layered PTES (Pit Thermal Energy Storage) model by precomputing PTES operational parameters into a single dataset, wiring per-layer PTES components into the sector network, and adding custom solver constraints for layered PTES behavior.

Changes:

  • Replace multiple PTES profile outputs with a unified ptes_operations dataset built via a new PtesApproximator.
  • Extend sector-network building to create per-layer PTES buses/stores/charger-discharger links and inter-layer links.
  • Add layered-PTES constraints in solve_network.py and propagate the new PTES operations input through Snakemake rules.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
scripts/solve_network.py Excludes layered PTES from generic TES constraints; adds layered PTES custom constraints and loads ptes_operations in extra_functionality.
scripts/prepare_sector_network.py Builds layered PTES components from ptes_operations and adjusts PTES boosting/HP handling.
scripts/definitions/heat_system.py Extends heat pump cost mapping for PTES layers and updates waste-heat enum member references.
scripts/definitions/heat_source.py Adds PTES layer heat sources and treats them as STORAGE across helper logic.
scripts/build_ptes_operations/run.py Switches PTES operational profile building to the new PtesApproximator and writes unified dataset.
scripts/build_ptes_operations/ptes_temperature_approximator.py Removes legacy PTES temperature/profile approximator implementation.
scripts/build_ptes_operations/ptes_approximator.py Adds new approximator producing layered parameters (weights, availability, boost ratios, interlayer coefficients).
scripts/build_heat_source_utilisation_profiles.py Uses ptes_operations for PTES(-layer) temperatures and cooling assumptions; closes dataset when enabled.
scripts/build_cop_profiles/run.py Uses ptes_operations for PTES(-layer) source temperatures; closes dataset when enabled.
rules/solve_perfect.smk Adds conditional PTES operations input to solve rule.
rules/solve_overnight.smk Adds conditional PTES operations input to solve rule.
rules/solve_myopic.smk Adds conditional PTES operations input to solve rule.
rules/build_sector.smk Replaces PTES temp/bottom inputs with unified PTES operations input; wires through dependent rules.
data/custom_costs.csv Adjusts PTES charger marginal cost and adds PTES discharger marginal cost entry.
config/config.default.yaml Adds layered PTES config stanza (num_layers, heat_transfer_coefficient).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +79 to 81
top_temp = float(snakemake.params.top_temperature)
bottom_temp = float(snakemake.params.bottom_temperature)

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

top_temperature/bottom_temperature are cast to float, but config validation allows the literals 'forward'/'return'. With such a config this will crash before any helpful error is raised. Either keep support for these string options, or validate early and raise a clear error if only constant temperatures are supported now.

Suggested change
top_temp = float(snakemake.params.top_temperature)
bottom_temp = float(snakemake.params.bottom_temperature)
top_temperature_param = snakemake.params.top_temperature
bottom_temperature_param = snakemake.params.bottom_temperature
unsupported_temperature_refs = {"forward", "return"}
for param_name, param_value in (
("top_temperature", top_temperature_param),
("bottom_temperature", bottom_temperature_param),
):
if isinstance(param_value, str) and param_value in unsupported_temperature_refs:
raise ValueError(
f"PTES configuration for '{param_name}' uses the unsupported value "
f"'{param_value}'. This script currently only supports constant "
"temperatures (numeric values). Please specify a fixed temperature "
"in degrees Celsius in the configuration."
)
top_temp = float(top_temperature_param)
bottom_temp = float(bottom_temperature_param)

Copilot uses AI. Check for mistakes.
Comment on lines +3100 to 3102
ptes_ds = xr.open_dataset(ptes_operations_file)
num_layers = int(ptes_ds.attrs["num_layers"])

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

ptes_ds is opened but never closed in this code path, which can leak file handles (especially when add_heat is called repeatedly). Use a context manager (with xr.open_dataset(...) as ptes_ds:) or ensure ptes_ds.close() is called once all PTES-related components (including resistive boosting) are added.

Copilot uses AI. Check for mistakes.
Comment on lines +1093 to +1128
For each interlayer link:
p_{l→l+1, t} = -Ψ_{l,l+1} · e_{l, t} ∀ t

where Ψ is the interlayer transfer coefficient and e_{l,t} is the
state-of-energy of the upper layer store.
"""
interlayer_links = n.links.index[
n.links.carrier.str.contains("water pits interlayer")
]

if interlayer_links.empty:
return

constraints = []
for link_name in interlayer_links:
# Extract pair index from link name (e.g., "... inter layer 0-layer 1" → 0)
pair_str = link_name.split("inter ")[-1]
pair_idx = int(pair_str.split("layer ")[1].split("-")[0])
heat_transfer_coef = float(
ptes_ds["interlayer_heat_transfer_coefficients"]
.sel(layer_pair=pair_idx)
.item()
)

upper_bus = n.links.at[link_name, "bus0"]
# Find the store on the upper bus
upper_store = n.stores.index[n.stores.bus == upper_bus]
if upper_store.empty:
continue
upper_store = upper_store[0]

link_p = n.model["Link-p"].loc[:, link_name]
store_e = n.model["Store-e"].loc[:, upper_store]
# p = Ψ · e
constraints.append(link_p - heat_transfer_coef * store_e)

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The interlayer flow docstring states p = -Ψ · e but the implementation constrains Link-p - Ψ·Store-e == 0 (no minus sign). Please align the sign convention (either update the constraint or correct the documentation) so that the interlayer link direction matches the intended heat diffusion direction.

Copilot uses AI. Check for mistakes.
Comment on lines 231 to +245
# Check if this is an excess-heat-sourced heat pump
if heat_source in [
HeatSource.PTES,
HeatSource.PTES_LAYER_0,
HeatSource.PTES_LAYER_1,
HeatSource.PTES_LAYER_2,
HeatSource.GEOTHERMAL,
HeatSource.SEA_WATER,
HeatSource.RIVER_WATER,
HeatSource.ELECTROLYSIS_waste,
HeatSource.FISCHER_TROPSCH_waste,
HeatSource.SABATIER_waste,
HeatSource.HABER_BOSCH_waste,
HeatSource.METHANOLISATION_waste,
HeatSource.FUEL_CELL_waste,
HeatSource.ELECTROLYSIS_WASTE,
HeatSource.FISCHER_TROPSCH_WASTE,
HeatSource.SABATIER_WASTE,
HeatSource.HABER_BOSCH_WASTE,
HeatSource.METHANOLISATION_WASTE,
HeatSource.FUEL_CELL_WASTE,
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

HeatSource members for waste heat were renamed to *_WASTE, but other parts of the codebase still reference the old names (e.g. scripts/lib/validation/config/sector.py uses HeatSource.ELECTROLYSIS_waste, etc.). This will raise AttributeError when loading config validation. Please update those remaining references (or provide backwards-compatible aliases) as part of this PR to avoid breaking imports.

Copilot uses AI. Check for mistakes.
Comment on lines +216 to 221
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."
)
Copy link

Copilot AI Mar 26, 2026

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.
num_layers=layered_config["num_layers"],
design_top_temperature=snakemake.params.design_top_temperature,
design_bottom_temperature=snakemake.params.design_bottom_temperature,
design_standing_losses=0.0, # placeholder; actual value set from costs data
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

PtesApproximator is instantiated without the required interlayer_heat_transfer_coefficient argument, which will raise a TypeError at runtime. Either pass the value from sector.district_heating.ptes.layered.heat_transfer_coefficient (or similar) or make the constructor parameter optional with a default and document the default.

Suggested change
design_standing_losses=0.0, # placeholder; actual value set from costs data
design_standing_losses=0.0, # placeholder; actual value set from costs data
interlayer_heat_transfer_coefficient=layered_config["heat_transfer_coefficient"],

Copilot uses AI. Check for mistakes.
Comment on lines +3140 to +3156
heat_source = HeatSource(f"ptes{layer_suffix}")
n.add(
"Bus",
heat_source.resource_bus(nodes, heat_system),
location=nodes,
carrier=f"{heat_system} ptes heat",
unit="MWh_th",
)
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"],
)
n.add(
"Link",
nodes,
suffix=f" {heat_system} water pits discharger{layer_suffix}",
bus0=nodes + f" {heat_system} water pits{layer_suffix}",
bus1=heat_source.resource_bus(nodes, heat_system),
carrier=f"{heat_system} water pits discharger",
efficiency=costs.at[
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Layer discharge is wired to heat_source.resource_bus(...) where heat_source is ptes layer X, creating buses like <node> urban central ptes layer 0 heat. Unless those layer heat sources are also included in params.heat_sources (and have utilisation/COP profiles built), these buses will be disconnected from the main <node> urban central heat bus. Either keep all PTES layer discharge on the aggregate ptes resource bus, or ensure the config/params and profile-building rules automatically include the per-layer PTES heat sources when num_layers > 1.

Copilot uses AI. Check for mistakes.
in [
HeatSource.PTES_LAYER_0,
HeatSource.PTES_LAYER_1,
HeatSource.PTES_LAYER_2,
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The special-casing for layered PTES heat pump capital_cost only covers PTES_LAYER_0..2, but the enum defines PTES_LAYER_3 and PTES_LAYER_4 as well. If num_layers > 3, the extra layers will incorrectly receive full heat pump capex. Consider handling all ptes layer * sources (or all HeatSourceType.STORAGE except the aggregate PTES) consistently.

Suggested change
HeatSource.PTES_LAYER_2,
HeatSource.PTES_LAYER_2,
HeatSource.PTES_LAYER_3,
HeatSource.PTES_LAYER_4,

Copilot uses AI. Check for mistakes.
Comment on lines 231 to 247
# Check if this is an excess-heat-sourced heat pump
if heat_source in [
HeatSource.PTES,
HeatSource.PTES_LAYER_0,
HeatSource.PTES_LAYER_1,
HeatSource.PTES_LAYER_2,
HeatSource.GEOTHERMAL,
HeatSource.SEA_WATER,
HeatSource.RIVER_WATER,
HeatSource.ELECTROLYSIS_waste,
HeatSource.FISCHER_TROPSCH_waste,
HeatSource.SABATIER_waste,
HeatSource.HABER_BOSCH_waste,
HeatSource.METHANOLISATION_waste,
HeatSource.FUEL_CELL_waste,
HeatSource.ELECTROLYSIS_WASTE,
HeatSource.FISCHER_TROPSCH_WASTE,
HeatSource.SABATIER_WASTE,
HeatSource.HABER_BOSCH_WASTE,
HeatSource.METHANOLISATION_WASTE,
HeatSource.FUEL_CELL_WASTE,
]:
return f"{self.central_or_decentral} excess-heat-sourced heat pump"
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

heat_pump_costs_name special-cases PTES layers only up to PTES_LAYER_2, but HeatSource now defines PTES_LAYER_3 and PTES_LAYER_4 as well. If additional layers are configured, they will fall through to the non-excess-heat heat pump cost category. Extend the list (or use heat_source.source_type == HeatSourceType.STORAGE/string matching) so all PTES layers map consistently.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants