Skip to content

Refactor: update heat source handling, introduce PyPS-onic pre-heating for geothermal and PTES#1893

Open
amos-schledorn wants to merge 79 commits intomasterfrom
refactor-ptes-boosting
Open

Refactor: update heat source handling, introduce PyPS-onic pre-heating for geothermal and PTES#1893
amos-schledorn wants to merge 79 commits intomasterfrom
refactor-ptes-boosting

Conversation

@amos-schledorn
Copy link
Contributor

@amos-schledorn amos-schledorn commented Nov 10, 2025

Supersedes #1765 .

This feature implements some refactoring of how Pit Thermal Energy Storage (PTES) and heat sources are handled.
PTES boosting is now possible without custom constraints. Instead, energy balancing is handled more PyPS-onicly (yes, that's a word now!) through a few extra busses and links.

Geothermal boosting is introduced similarly with a preheater to better approximate real-world behaviour.
This results in an overall cleaner/more generic integration of heat sources.

1. New HeatSource Enum Class (scripts/definitions/heat_source.py)

A new enumeration class has been added to categorize heat sources:

Heat Source Properties
AIR, GROUND, SEA_WATER Inexhaustible sources - no resource bus needed
GEOTHERMAL, RIVER_WATER Limited sources requiring a generator and resource bus
PTES Special case - storage discharge treated as heat source

Key properties for each source:

  • requires_bus: Whether a resource bus is needed (True for limited sources)
  • requires_generator: Whether a generator component is needed (geothermal, river_water)
  • has_constant_temperature: Whether temperature is time-invariant (geothermal)

The class also provides methods for:

  • Getting capital costs and lifetimes
  • Determining heat pump bus2 connections
  • Calculating efficiency2 for heat pumps (accounts for heat drawn from source)
  • Generating carrier and bus names

2. build_ptes_operations Rule

New configuration parameters:

sector:
  district_heating:
    ptes:
      enable: true
      temperature_dependent_capacity: false  # e_max_pu varies with ΔT
      charge_boosting_required: false  # NOT IMPLEMENTED
      discharge_resistive_boosting: false  # use resistive heaters vs heat pumps
      top_temperature: 90  # or "forward" for dynamic
      bottom_temperature: 10  # or "return" for dynamic
      design_top_temperature: 90
      design_bottom_temperature: 35

Outputs:

  1. temp_ptes_top_profiles: PTES top layer temperature profile
  2. ptes_e_max_pu_profiles: Normalized storage capacity (accounts for temperature-dependent capacity)
  3. ptes_boost_per_discharge_profiles: Ratio of boost energy to discharge energy when T_forward > T_top. Used iff sector:district_heating:ptes:discharge_resistive_boosting: true

3. build_heat_source_utilisation_profiles Rule

Renamed from build_heat_source_direct_utilisation_profiles. Calculates when heat sources can be used and in what mode:

Outputs:

  1. heat_source_direct_utilisation_profiles: Binary (1.0 when T_source ≥ T_forward for direct supply) - this already exists
  2. heat_source_preheater_utilisation_profiles: Efficiency when T_return < T_source < T_forward (preheating mode) - this is new

4. Changes in prepare_sector_network:add_heat: new bus structure

a) For exhaustible heat sources (currently geothermal, river-water and PTES with HP boosting but more to come in #1944)

Generator (only geothermal / river_water)
    │
    ▼
┌──────────────────┐
│   Resource Bus   │
└────────┬─────────┘
         │
         │  Utilisation Link
         │
    ┌────┴────────────────────────────┐
    │                                 │
    │ eff = 1 if T_source ≥ T_forward │ eff2 = 1 - eff
    │        else 0                   │
    ▼                                 ▼
urban                          Preheater-Input Bus
central                              │
heat                                 │  Preheater Link
                                     │
                ┌────────────────────┴────────────────────────┐
                │                                             │
                │ eff = T_source - T_return                            │ eff2 = 1 - eff
                │       ─────────────────────────             │
                │       (T_source - T_return) + ΔT_cooling    │
                ▼                                             ▼
            urban                                      Heat-Pump-Input Bus
            central                                          │
            heat                                             │
                                                             │  Heat Pump Link
                                                             │
                                        ┌────────────────────┴───┐
                                        ▲                        │
                                        │ eff = 1/COP            │ eff2 = 1 - 1/COP
                                        │                        ▼
                                Electricity                  urban
                                   Bus                       central
                                                             heat

For PTES, the generator is replaced with the urban central water pits discharger link.

b) For Inexhaustible Sources (air / ground / sea_water)

No buses created — these sources don't need resource tracking, thus

  • heat_source.requires_bus returns False
  • heat_source.get_heat_pump_input_bus() returns "", which is also bus2 for heat pumps.

Links created:

  1. Heat Pump Link: Electricity → urban central heat
    • bus0: urban central heat (inverted link configuration)
    • bus1: Electricity Bus (eff = 1/COP)
    • bus2: not used (empty string)
    • No resource bus needed since the source is inexhaustible
                          Heat Pump Link
                                │
               ┌────────────────┴───┐
               ▲                    │
               │ eff = 1/COP        │ (no eff2 / bus2)
               │                    ▼
       Electricity              urban
          Bus                   central
                                heat

c) PTES with Resistive Boosting

When ptes:discharge_resistive_boosting: true, PTES uses resistive heaters instead of heat pumps for the temperature lift. The structure bypasses the cascading utilisation/preheater/heat-pump chain.

Key calculation (in build_ptes_operations):

  • α = boost_per_discharge = (T_forward - T_top) / (T_top - T_bottom)
  • This is the ratio of boost energy needed relative to PTES discharge energy
  • The water pits discharger link still points to the PTES resource bus urban central ptes heat
  • Preheater and direct utilisation busses and links are created but heat_source.requires_heat_pump() returns False.
  • Since no heat pump is created, all PTES heat is either routed through direct utilisation or resistive boosting, not through preheating.
                  ┌──────────────────┐
                  │   PTES Store     │
                  │   (water pits)   │
                  └────────┬─────────┘
                           │
                           │  Discharger Link                            
                          ▼
                  ┌─────────-────────┐
                  │   Resource Bus   │----> Utilisation Link -----> Urban central heat
                  │   (ptes heat)    │
                  └────────┬─────────┘
                         │
                         │  Resistive Booster Link
                         │
                        ┌────────────┴────────────────────┐
                        ▲                                 │
                        │ eff = α / (α + 1)               │ eff2 = 1 / (α + 1)
                        │                                 ▼
                 Resistive Heat                       urban
                      Bus  ──────────────┐            central
                        │                │            heat
                        │                │              ▲
                        │                │              │
           Resistive Heater        Stand-alone Link     │
           eff = 1                 eff = 1              │
                ▲                        │              │
                │                        └──────────────┘
         Electricity
            Bus

With resistive boosting, the ratio α determines how much of the output comes from PTES discharge vs electric heating:

  • eff2 = 1/(α+1): use 1 unit of PTES heat to produce α+1 units of district heating
  • eff = α/(α+1) = 1 - eff2: energy conservation

5. Further onfiguration Changes

  • Renamed heat_pump_sourcesheat_sources
  • Renamed testtes (tank thermal energy storage)
  • Removed limited_heat_sources, direct_utilisation_heat_sources, temperature_limited_stores

Open issues

  • integration into brownfield setups
  • decide upon default behaviour (disable PTES/enable PTES with/without boosting; temperatures, ...)
  • add bus structure sketches to docs
  • calculation of pre-heating ratios needs reviewing

Checklist

  • I tested my contribution locally and it works as intended.
  • Code and workflow changes are sufficiently documented.
  • Changed dependencies are added to envs/environment.yaml.
  • Changes in configuration options are added in config/config.default.yaml.
  • Changes in configuration options are documented in doc/configtables/*.csv.
  • Sources of newly added data are documented in doc/data_sources.rst.
  • A release note doc/release_notes.rst is added.

Testing

Config

  name: 
  - ptes_90-10_dynamic_capacity_hpBoosting
  - ptes_fwd-ret_dynamic_capacity_hpBoosting
  - ptes_90-35_static_capacity_rhBoosting
  - ptes_90-10_dynamic_capacity_rhBoosting_geothermal
  - ptes_200-10_dynamic_capacity_hpBoosting
  scenarios:
    enable: true
    file: dev/scenarios.yaml
    shared_resources:
      policy: true

foresight: overnight

scenario:
  clusters:
  - 8
  planning_horizons:
  - 2040

clustering:
  temporal:
    resolution_sector: 25h

countries: ['DE', 'DK']

plotting:
  energy_threshold: 0
  enable_heat_source_maps: true
  interactive_bus_balance: 
    bus_name_pattern: "*urban central*"

sector:
  district_heating:
    supply_temperature_approximation:
      max_forward_temperature_baseyear:
        DE: 110
        DK: 80
      min_forward_temperature_baseyear:
        DE: 75
        DK: 55
      return_temperature_baseyear:
        DE: 60
        DK: 50
      relative_annual_temperature_reduction: 0.0
  ttes: false

##### scenarios.yaml #####


ptes_90-10_dynamic_capacity_hpBoosting:
  sector:
    heat_sources:
      urban central:
        - air
        - ptes
    district_heating:
      ptes:
        top_temperature: 90
        bottom_temperature: 10
        temperature_dependent_capacity: true
        discharge_resistive_boosting: false

ptes_fwd-ret_dynamic_capacity_hpBoosting:
  sector:
    heat_sources:
      urban central:
        - air
        - ptes
    district_heating:
      ptes:
        top_temperature: forward
        bottom_temperature: return
        temperature_dependent_capacity: true
        discharge_resistive_boosting: false

ptes_90-35_static_capacity_rhBoosting:
  sector:
    heat_sources:
      urban central:
        - air
        - ptes
    district_heating:
      ptes:
        top_temperature: 90
        bottom_temperature: 35
        temperature_dependent_capacity: false
        discharge_resistive_boosting: true

ptes_90-10_dynamic_capacity_rhBoosting_geothermal:
  sector:
    heat_sources:
      urban central:
        - air
        - ptes
        - geothermal
    district_heating:
      ptes:
        top_temperature: 90
        bottom_temperature: 10
        temperature_dependent_capacity: true
        discharge_resistive_boosting: true


ptes_200-10_dynamic_capacity_hpBoosting:
  sector:
    heat_sources:
      urban central:
        - air
        - ptes
    district_heating:
      ptes:
        top_temperature: 200
        bottom_temperature: 10
        temperature_dependent_capacity: true
        discharge_resistive_boosting: false

Results

Parameters

(look reasonable)
image

Example energy balance (Jan 3rd, ptes_90-10_dynamic_capacity_hpBoosting

DE0 1 urban central ptes heat bus

PTES discharge: 369 MWh

image
DE0 1 urban central ptes medium-temperature heat bus

Discharge is fully absorbed by pre-heater, since no direct utilisation allowed

image
DE0 1 urban central ptes return-temperature heat bus

231 MWh is used as heat pump input

image
DE0 1 urban central heat bus

Remaining 138 MWh are used by preheater to heat return flow
Heat pump output is 282 MWh > 231 MWh due to electricity input

image

Entire year, ptes_90-10_dynamic_capacity_hpBoosting, DE0 1

Discharging over the entire year follows parameters shown above, with direct utilisation mostly in the summer.
There are some periods where both direct utilisation and pre-heating are happening due to the 25h resolution.

image

Entire year, ptes_90-10_dynamic_capacity_hpBoosting, DK1 0

Direct utilisation is possible throughout the entire year due to lower forward temperatures.

image

Entire year, ptes_90-10_dynamic_capacity_rhBoosting_geothermal, DE0 0

Here, the pre-heating ratio for geothermal seems a little high but matches the logic, as (T_source - T_return) / (T_source - T_return + 6K) = 0.45.

image

EDIT

I've also updated build_geothermal_heat_source_potentials by introducing scaling of Manz et al.'s data.
Manz et al. assume a difference of 15K. This rule now scales the potentials based our actual temperature delta:

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

EDIT 2

The example results are using a buggy scaling of p_max_pu for geothermal heating. I've fixed that bug resulting in the model not building goethermal heating anymore. I've checked that it can (by lowering CAPEX) and pre-heating ratios are still valid.
(just a note that when executing these configs, geothermal has to be forced in to get comparable results, so I'm leaving the "faulty" results here)

EDIT 3

Following a suggestion of @fneum's, I've compared a scenario without PTES & geothermal heating, i.e. those technologies that should be affected by the feature, to the master:

only_unaffected_technologies:
  sector:
    district_heating:
      ptes:
        enable: false
    heat_sources:
      urban central:
        - air
        - river_water
        - sea_water
    ttes: true
  solving:
    options:
      noisy_costs: false

On the master, I have removed the section in prepare_sector_network that builds PTES.
-> The objective value is constant across both feature and master to the 5th digit:
feature=1.16952e+11 master=1.16952e+11 (Δ=57755.6, +0.000%)

I've also compared runs with default settings in the feature and master (still DE/DK-25h) with similar results (different colours though).

District heating balance in master, default settings:
image

and feature, default settings:
image

Energy balance in master:
image

and feature:
image

@amos-schledorn amos-schledorn marked this pull request as draft November 10, 2025 11:24
@amos-schledorn
Copy link
Contributor Author

Thanks for the review, @cpschau. I have

  • renamed return_temperature_bus and medium_temperature_bus to heat_pump_input_bus and preheater_input_bus
  • removed the manual requires_preheater attribute: the code now handles this dynamically by routing all exhaustible heat sources
  • tested the updated workflow (not documenting results again) by comparing model dispatch to manually computed boosting and preheating requirements

Copy link
Contributor

@cpschau cpschau left a comment

Choose a reason for hiding this comment

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

Logic for sink inlet T must be changed, and e_nom_min for PTES must be removed.

nodes,
suffix=f" {heat_system} water pits discharger",
bus0=nodes + f" {heat_system} water pits",
bus1=HeatSource.PTES.resource_bus(nodes, heat_system),
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this is the "urban central ptes heat" bus, that is added just before in L 3166? Some comments would be helpful to understand what is going on. A compromise has to be found between keeping prepare_sector_network lean but also understandable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But isn't this way more readable than a string? You can just hit f12/hover to get a full description or highlight where else it's being used.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure, but maybe a comment above would be helpful to understand that the bus components initialized are these exact resource buses.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gotcha. Didn't see that the bus creation uses an explicit string. It now does and I've also added examples to the HeatSource.resource_bus method.

return_temp = return_temperature.sel(name=regions)

# Broadcast return_temperature to match forward_temperature dimensions
return_temp_broadcast = return_temp.broadcast_like(forward_temp)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do forward and return T ever arrive with different temporal indices?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

return_temp should be constant and not time-indexed, thus the broadcast.

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.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is wrong. The sink inlet temperature should never be at forward T level but either at source T or return T level. The sink outlet T should be equal to the forward level after liftinf the temeprature.

Instead it should be:

 return xr.where(
        source_temperature > central_heating_return_temperature,
        source_temperature,
        central_heating_return_temperature,
    )

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch! I think it's actually supposed to be

return xr.where(
        source_temperature > central_heating_return_temperature,
        central_heating_return_temperature,
        source_temperature,
)

(which is what I implemented)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it is xr.where(condition, value if true, value if false), so it should be flipped like I suggested before, if we are talking about the sink side.
Screenshot from 2026-02-05 09-35-13

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right of course, brain-farted a little - fixed.

@fneum
Copy link
Member

fneum commented Feb 11, 2026

  1. Is there any way to split this PR into multiple chunks? This would massively simplify the review. I started to review, but I found it hard to structure my approach. There seem to be some elements that are orthogonal and some that build up on each other.

  2. Are the comparisons at the top of the PR up to date? Last edit seems to be from December.

  3. If I understand correctly, there are changes to the topology of buses/links. How many more buses and links are added (as a function of regions) and what was the computational impact?

@amos-schledorn
Copy link
Contributor Author

amos-schledorn commented Feb 12, 2026

  1. Is there any way to split this PR into multiple chunks? This would massively simplify the review. I started to review, but I found it hard to structure my approach. There seem to be some elements that are orthogonal and some that build up on each other.

    1. Are the comparisons at the top of the PR up to date? Last edit seems to be from December.

    2. If I understand correctly, there are changes to the topology of buses/links. How many more buses and links are added (as a function of regions) and what was the computational impact?

Thanks for looking into that - I know this is a lot of code!

  1. Not really - with the exception of adjusting geothermal heat source cooling. Lmk if you'd like this moved.
  2. Updated the description - sorry! Numerical results have not been updated.
  3. Fair point. I didn't record the computational impact. Per exhaustible heat source (by default PTES and geothermal, in feat:improve PtX excess heat handling #1944 also PtX excess heat), 2 additional busses and links should be created per region. For heat-sources with temperatures always above the forward temperature, all efficiencies of all links but direct utilisation are zero, which I'd expect strong solvers like Gurobi to handle well in presolve. For heat sources always below return temperature, preheating and direct utilisation efficiencies are zero (i.e. river-water heat, not enabled by default).
    TLDR: 4 busses + 4 links per region.

Review giude:

  • start with PR intro, pull the branch, review config changes
  • review prepare_sector_network, inspect relevant heat_source attributes and methods assuming preheater, utilisation, COP profiles are correct
  • review those profiles, i.e. changes in build_heat_source_utilisation, build_geothermal_heat_potential, build_cop_profiles
  • the rest are minor supporting changes

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.

4 participants