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:
If applicable:
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
Uh oh!
There was an error while loading. Please reload this page.
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_profilesandbuild_heat_source_utilisation_profilesrules into a singlebuild_heat_source_profilesrule 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:
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_profilesandbuild_heat_source_utilisation_profilesrules.build_geothermal_heat_potentialscales the extractable geothermal power by how far the source stream is cooled (introduced in #1893):, and previously read the same flatheat_source_coolingconstant. 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_profilesproduces the three profiles the sector network already consumes, plus a new fourth output:cop_profilesheat_source_direct_utilisation_profilesheat_source_preheater_utilisation_profilesheat_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 flatheat_source_coolingvalue 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:compute_heat_pump_coolingstarts from the previousheat_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.heat_source_coolingvalue, 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_potentialtakes that file as a new input (selecting the geothermal / urban-central slice) and uses it inscale_heat_source_powerin place of its formerheat_source_coolingparam, which is removed from the rule.build_cop_profilesandbuild_heat_source_utilisation_profilesalways 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_perfectandplot_cop_profilesstill resolvecop_profilesand the two utilisation profiles by path. The only workflow change is thatheat_sourcesis now passed as the full{system: [sources]}dict (asbuild_cop_profileshad 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, default6): unchanged for non-preheating sources. It now also serves as the starting guess for the iterative solve.heat_pump_cooling_iterative(new, bool, defaulttrue): turns the iterative solve on. Set it tofalseto fall back to flat cooling for all sources. That path reproduces pre-this-feature results exactly.log_heat_pump_cooling_iterations(new, bool, defaultfalse): a debugging aid. When enabled, the full per-node, per-timestep, per-iteration trace (forward and source temperature, cooling, COP) is written toheat_pump_cooling_iterations_<...>.csvalongsidecop_profiles. Might be removed in final version.The two new settings are registered in the config-validation model (
_DistrictHeatingConfiginscripts/lib/validation/config/sector.py) and inconfig/config.default.yaml.File changes
New
scripts/build_heat_source_profiles/run.py- the merged rule andcompute_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/andscripts/build_heat_source_utilisation_profiles.py.Changed
rules/build_sector.smk- the two rules replaced bybuild_heat_source_profiles, which passes the fullheat_sourcesdict and the two new params and declares theheat_source_cooling_profilesoutput;build_geothermal_heat_potentialswaps itsheat_source_coolingparam for that file as an input.scripts/build_geothermal_heat_potential.py-scale_heat_source_powernow takes the cooling as a(time, name)profile read fromheat_source_cooling_profilesinstead of a scalar from the config.config/config.default.yamlandscripts/lib/validation/config/sector.py- the two new settings.doc/sector.rst- the autodoc entry renamed frombuild_cop_profilestobuild_heat_source_profiles.scripts/definitions/heat_source.py- theSee Alsodocstring now points at the new rule.scripts/build_central_heating_temperature_profiles/run.py- fixed a straymock_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:
doc/release_notes.rst.If applicable:
scripts/lib/validation.doc/*.rstfiles.Testing
config
I've tested the above config as
featagainst the base branch (base) and the feature with disabled iterative cooling computation (base-eq).Total system costs
Costs stay constant across
baseandbase-eqand increase very slightly infeat:image
Heat source cooling
(all results for
DE0 0node)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