Conversation
(operational for hard-coded layers, dummy heat transfer coef and resistive boosting)
There was a problem hiding this comment.
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_operationsdataset built via a newPtesApproximator. - 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.pyand 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.
| top_temp = float(snakemake.params.top_temperature) | ||
| bottom_temp = float(snakemake.params.bottom_temperature) | ||
|
|
There was a problem hiding this comment.
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.
| 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) |
| ptes_ds = xr.open_dataset(ptes_operations_file) | ||
| num_layers = int(ptes_ds.attrs["num_layers"]) | ||
|
|
There was a problem hiding this comment.
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.
| 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) | ||
|
|
There was a problem hiding this comment.
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.
| # 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, |
There was a problem hiding this comment.
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.
| 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." | ||
| ) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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"], |
| 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[ |
There was a problem hiding this comment.
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.
| in [ | ||
| HeatSource.PTES_LAYER_0, | ||
| HeatSource.PTES_LAYER_1, | ||
| HeatSource.PTES_LAYER_2, |
There was a problem hiding this comment.
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.
| HeatSource.PTES_LAYER_2, | |
| HeatSource.PTES_LAYER_2, | |
| HeatSource.PTES_LAYER_3, | |
| HeatSource.PTES_LAYER_4, |
| # 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" |
There was a problem hiding this comment.
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.
Closes # (if applicable).
Changes proposed in this Pull Request
Checklist
Required:
doc/release_notes.rst.If applicable:
scripts/lib/validation.doc/*.rstfiles.