Skip to content

replace build_cop_profiles and build_heat_source_utilisation_profiles…#2184

Draft
amos-schledorn wants to merge 11 commits into
refactor-ptes-boostingfrom
numeric-delta-t-computation
Draft

replace build_cop_profiles and build_heat_source_utilisation_profiles…#2184
amos-schledorn wants to merge 11 commits into
refactor-ptes-boostingfrom
numeric-delta-t-computation

Conversation

@amos-schledorn

@amos-schledorn amos-schledorn commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Consistent heat-pump cooling for COP and heat-source utilisation profiles

For preheating heat sources (PTES and geothermal) the temperature drop the heat pump imposes on its source is not a free parameter — the heat pump's energy balance fixes it — yet that same drop feeds back into the COP correlation that determines the balance. This PR replaces the previous constant-cooling assumption with an iterative solve that finds the cooling and the COP consistently, merges the former build_cop_profiles and build_heat_source_utilisation_profiles rules into a single build_heat_source_profiles rule that produces both, and feeds the solved cooling into the geothermal heat-potential scaling, which previously used the same flat constant.

Background

District-heating COPs come from the Jensen et al. (2018) correlation in CentralHeatingCopApproximator. Among its inputs are the source inlet and source outlet temperatures — that is, how far the heat pump cools the stream it draws heat from. The gap between them, call it ΔT_cool, was set by a single config number, heat_source_cooling (6 K), for every source and every hour.

That is fine for an ambient source like air, where the evaporator ΔT is essentially a design choice. It is not fine for a source that is first used to preheat the return flow and then lifted to the network forward temperature. Under the plant layout we assume for those sources — a single storage stream, a preheater followed by the evaporator, equal mass flows on both sides — the heat pump's energy balance ties the source-side cooling directly to the lift and the COP:

ΔT_cool = (COP - 1) / COP * (T_forward - T_source)

because the heat pulled from the source is the delivered heat minus the electrical work, so Q_source / Q_sink = (COP - 1) / COP. The catch is that the COP on the right-hand side is itself a function of ΔT_cool: the cooling sets the source outlet temperature that goes into the correlation. So ΔT_cool and the COP are bound by one physical relation, and you cannot pin ΔT_cool to a constant and read the COP off the correlation at that constant and expect the two to agree.

The current implemementation does exactly that. The reported COP and the cooling implied by its own energy balance are therefore inconsistent — the cooling is over-specified, once by the config constant and once (implicitly) by the heat pump's energy balance.

The same ΔT_cool also appears in the preheater utilisation profile, in the denominator that splits source heat between direct preheating and the heat-pump cold side. Whatever value we settle on has to be used in both the COP and the utilisation profile, which is the second reason for merging the build_cop_profiles and build_heat_source_utilisation_profiles rules.

build_geothermal_heat_potential scales the extractable geothermal power by how far the source stream is cooled (introduced in #1893):, and previously read the same flat heat_source_cooling constant. With the solve in place it now uses the solved cooling instead, so the geothermal potential, the COP and the utilisation profiles all agree on one ΔT_cool.

Code changes

build_heat_source_profiles produces the three profiles the sector network already consumes, plus a new fourth output:

  • cop_profiles
  • heat_source_direct_utilisation_profiles
  • heat_source_preheater_utilisation_profiles
  • heat_source_cooling_profiles (new) - the solved ΔT_cool, indexed by (time, name, heat_source, heat_system). When the iterative solve is off, it simply holds the flat heat_source_cooling value broadcast over all dimensions, so downstream consumers see a uniform interface either way.

For each (heat system, source, node, snapshot) it derives the source and sink inlet temperatures from the existing logic and then decides the cooling:

  • Preheating central sources (PTES, geothermal): the new function compute_heat_pump_cooling starts from the previous heat_source_cooling, evaluates the COP resulting from that initial value, recomputes the cooling from (COP - 1) / COP * (T_forward - T_source), and repeats until the cooling stops changing.
  • All other heat sources (currently) - the toggle off, non-preheating central sources such as air, and all decentral systems - keeps the flat heat_source_cooling value, exactly as before.

The solved cooling is then reused for the COP profile and the preheater-utilisation profile, and written out as heat_source_cooling_profiles.

build_geothermal_heat_potential takes that file as a new input (selecting the geothermal / urban-central slice) and uses it in scale_heat_source_power in place of its former heat_source_cooling param, which is removed from the rule.

build_cop_profiles and build_heat_source_utilisation_profiles always run on the same inputs (forward/return temperatures and the source temperature profiles), and with this change both depend on the same solved cooling. Keeping them apart would mean either computing heat pump cooling and COP profiles twice or shuttling them between rules.

This feature merges them and keeps the existing output file names, so nothing downstream needs to change: prepare_sector_network, solve_myopic/solve_perfect and plot_cop_profiles still resolve cop_profiles and the two utilisation profiles by path. The only workflow change is that heat_sources is now passed as the full {system: [sources]} dict (as build_cop_profiles had it) instead of just the urban-central list that the old utilisation rule used.

New config settings

All live under sector: district_heating:.

  • heat_source_cooling (existing, default 6): unchanged for non-preheating sources. It now also serves as the starting guess for the iterative solve.
  • heat_pump_cooling_iterative (new, bool, default true): turns the iterative solve on. Set it to false to fall back to flat cooling for all sources. That path reproduces pre-this-feature results exactly.
  • log_heat_pump_cooling_iterations (new, bool, default false): a debugging aid. When enabled, the full per-node, per-timestep, per-iteration trace (forward and source temperature, cooling, COP) is written to heat_pump_cooling_iterations_<...>.csv alongside cop_profiles. Might be removed in final version.

The two new settings are registered in the config-validation model (_DistrictHeatingConfig in scripts/lib/validation/config/sector.py) and in config/config.default.yaml.

File changes

New

  • scripts/build_heat_source_profiles/run.py - the merged rule and compute_heat_pump_cooling.
  • scripts/build_heat_source_profiles/{base,central_heating,decentral_heating}_cop_approximator.py - the COP approximators moved into the package so it is self-contained. They are identical to the old ones.

Removed

  • scripts/build_cop_profiles/ and scripts/build_heat_source_utilisation_profiles.py.

Changed

  • rules/build_sector.smk - the two rules replaced by build_heat_source_profiles, which passes the full heat_sources dict and the two new params and declares the heat_source_cooling_profiles output; build_geothermal_heat_potential swaps its heat_source_cooling param for that file as an input.
  • scripts/build_geothermal_heat_potential.py - scale_heat_source_power now takes the cooling as a (time, name) profile read from heat_source_cooling_profiles instead of a scalar from the config.
  • config/config.default.yaml and scripts/lib/validation/config/sector.py - the two new settings.
  • doc/sector.rst - the autodoc entry renamed from build_cop_profiles to build_heat_source_profiles.
  • scripts/definitions/heat_source.py - the See Also docstring now points at the new rule.
  • scripts/build_central_heating_temperature_profiles/run.py - fixed a stray mock_snakemake("build_cop_profiles") left over from a copy-paste; it now names its own rule (and would otherwise have referenced the deleted rule).

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.

Testing

config

foresight: overnight

scenario:
  clusters:
  - 8
  planning_horizons:
  - 2040

clustering:
  temporal:
    resolution_sector: 25h

countries: ['DE', 'DK']

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
    log_heat_pump_cooling_iterations: true
    heat_pump_cooling_iterative: true
    ptes:
       enable: true
       discharge_resistive_boosting: false
  heat_sources:
    urban central:
      - geothermal
      - ptes
      - air
  ttes: false
  chp:
    enable: false

solving:
  options:
    noisy_costs: false

I've tested the above config as feat against the base branch (base) and the feature with disabled iterative cooling computation (base-eq).

Total system costs

Costs stay constant across base and base-eq and increase very slightly in feat:
image

Heat source cooling

(all results for DE0 0 node)

As expected, heat-source cooling increases, especially for geothermal, given its lower temperature in comparison to PTES:
image

Accordingly, preheater ratios decrease (higher exploitation by heat pump than before):
image

As do COPs:
image

Since we assume a higher source-side cooling, the exploitable geothermal heat source power increases:
image

… with a single rule and compute heat_pump_coolling for COP approximation iteratively when enabled
@amos-schledorn amos-schledorn marked this pull request as draft June 1, 2026 16:14
@amos-schledorn amos-schledorn requested a review from cpschau June 1, 2026 16:14

@cpschau cpschau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks great! New structure with all scripts consolidated in build_heat_source_profiles is way cleaner.

Comment on lines +489 to +496
# Write the full per-node, per-timestep Picard trace only when logging is on.
if cooling_iteration_log:
iterations_path = (
Path(snakemake.output.cop_profiles).parent
/ f"heat_pump_cooling_iterations_base_s_{snakemake.wildcards.clusters}_{snakemake.wildcards.planning_horizons}.csv"
)
pd.concat(cooling_iteration_log, ignore_index=True).to_csv(
iterations_path, index=False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would be the first log-type resource afaik. Maybe the normal log of the snakemake rule suffices.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just realized that the additional log resource is a config option, so nvm.

@amos-schledorn amos-schledorn requested a review from cpschau June 10, 2026 16:29
@amos-schledorn amos-schledorn marked this pull request as ready for review June 10, 2026 16:30
@amos-schledorn amos-schledorn marked this pull request as draft June 12, 2026 12:18
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