Skip to content

Explicit representation of n largest district heating systems#1995

Draft
cpschau wants to merge 11 commits intomasterfrom
dh-subnodes
Draft

Explicit representation of n largest district heating systems#1995
cpschau wants to merge 11 commits intomasterfrom
dh-subnodes

Conversation

@cpschau
Copy link
Copy Markdown
Contributor

@cpschau cpschau commented Jan 22, 2026

District Heating Subnodes Feature

Summary

This PR introduces district heating (DH) subnode modeling to PyPSA-Eur, enabling finer spatial resolution for urban central heating systems. Key capabilities include:

  • Selectable n largest DH systems: Configurable number of subnodes selected by demand ranking from ISI data
  • Country filtering: Subset of modeled countries can be selected for subnode modeling via sector.district_heating.subnodes.countries
  • Parent node linkage: Subnodes are linked to their parent cluster buses for:
    • Electricity (HV for CHP generation and distribution grid for P2H supply of DH)
    • Resource supply (Gas/coal/oil/H2/waste/biomass)
    • CO₂ storage & global atmosphere buses
  • Subnode-specific data: Each subnode receives:
    • Own ambient temperature profiles (for COP calculations)
    • Heat pump COPs based on local conditions
    • Renewable heat potentials (solar thermal, geothermal, river_water etc.)
    • Network forward temperature timeseries
    • Heat demand profiles
  • Demand compensation: Additional subnodal demands are compensated by reduction of demand in respective parent node.

Data Source

The feature is based on ISI/seenergies district heating area shapes derived from heating density data. These shapes provide:

  • Geographic boundaries of DH systems
  • Annual heat demand estimates (GWh/a)
  • City/system labels

Structural Integration

New Scripts

Script Purpose
scripts/identify_district_heating_subnodes.py Identifies n largest DH systems, maps to parent clusters, extends onshore regions
scripts/prepare_district_heating_subnodes.py Extends energy/heat totals, district heat shares, industrial demand, and time-series data for subnodes
scripts/map_dh_systems_to_cities.py Maps DH system IDs to human-readable city names using GeoNames cities500 (see below)

Modified Scripts

Script Changes
prepare_sector_network.py Extended define_spatial() for district heating nodes; heat buses, loads, and technologies now iterate over heat_nodes (including subnodes); parent node mapping for resource bus lookups
rules/build_sector.smk New rules identify_district_heating_subnodes and prepare_district_heating_subnodes; conditional inputs for subnode-extended data files
Various data pre-processing scripts Extended to output subnode-specific COP, solar thermal, temperature, and PTES profiles

Configuration

sector:
  district_heating:
    subnodes:
      enable: false  # Toggle subnode modeling
      n_subnodes: 40  # Number of largest DH systems
      countries: []  # Empty = all countries; or specify subset
      demand_column: "Dem_GWh"  # ISI data column for demand
      label_column: "Label"  # ISI data column for city names

Testing

Test Configuration

Three scenarios are compared: master_branch (code before this PR), subnodes_off (PR code with subnodes disabled), and subnodes_on (PR code with subnodes enabled).

config/config_subnodes.yaml:

run:
  prefix: "test_subnodes"
  name:
    - subnodes_on
    - subnodes_off
  scenarios:
    enable: true
    file: config/scenarios_subnodes.yaml
scenario:
  clusters:
    - 4
clustering:
  temporal:
    resolution_sector: 25h
countries: [DK, DE, FR]
sector:
  district_heating:
    subnodes:
      enable: true
      n_subnodes: 10
      countries: [DK, DE]

config/scenarios_subnodes.yaml:

subnodes_on: {}

subnodes_off:
  sector:
    district_heating:
      subnodes:
        enable: false

master_branch: {}

Network Topology

image

Heat Demand Preservation

image

District Heating Energy Balance

image

Total System Costs

image

Scale-up

I also tested the feature with 39 clusters and 200 subnodes at 3h resolution including river water and geothermal as heat sources for district heating supply to ensure robustness. The network was solved in 6 h with max mem usage of 172 GB.

Here are some related results:

District heating balance

image

Composition of system costs:

image

District heating balance map

image Notably, the potentials for limited heat sources (river water in this case) in the parent nodes, are overestimated due to spatial aggregation.

References

Remaining TODOs

  • Handle missing countries in ISI data: Add graceful handling/warnings when ISI shapes are unavailable for selected countries
  • Map IDs to city names: Implement georeferenced mapping from ISI system IDs to human-readable city names
  • Config parameter mapping: Map config parameters (planning horizon, market share, energy demand projections) to ISI data assumptions (market share assumptions, marginal DH expansion costs)
  • Rebase with new DH features: Integrate with recent master branch additions:
    • Heat pump boosting
    • Lake water heat sources
    • Improved excess heat handling from industrial processes

@@ -0,0 +1,243 @@
,Unit,Values,Description
transport,--,"{true, false}",Flag to include transport sector.
heating,--,"{true, false}",Flag to include heating sector.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All doctables are gone and this needs to be merged in the pydantic schema

lkstrp and others added 9 commits January 27, 2026 16:22
- option to scale subnodal demands to align with ISI data assumptions (extracted in CSV)
- fix city name mapping includin merge of overlapping city areas
- adjust config validation
nodes = pop_layout.index
heat_nodes = pop_layout.index
parent_of_subnode = pd.Series(heat_nodes, index=heat_nodes)

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.

This seems overly complicated. Could we simplify that? Also, I'd suggest computing parent_of_subnode in a preceding rule.

Comment on lines +2975 to 2978
heat_demand_grouped = heat_demand.T.groupby(level=1).sum().T
heat_load = heat_demand_grouped[heat_nodes].multiply(
factor * (1 + options["district_heating"]["district_heating_loss"])
)
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.

Not sure why we need to define heat_demand_grouped. Doesn't seem to be used elsewhere, as far as I can see.

Comment on lines +2999 to +3005
heat_dsm_profile_raw = pd.read_csv(
heat_dsm_profile_file,
header=1,
index_col=0,
parse_dates=True,
)[nodes].reindex(n.snapshots)
)
heat_dsm_profile = heat_dsm_profile_raw[heat_nodes].reindex(n.snapshots)
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.

Again, heat_dsm_profile_raw seems unnecessary. Adding indexing by [heat_nodes] should do the trick.

Comment on lines +3007 to 3011
heat_demand_res_space = (
heat_demand[["residential space"]].T.groupby(level=1).sum().T
)
e_nom = heat_demand_res_space[heat_nodes].multiply(factor)

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.

Same.

Comment on lines +3158 to 3161
ptes_supp_data = xr.open_dataarray(ptes_direct_utilisation_profile)
ptes_supplemental_heating_required = (
xr.open_dataarray(ptes_direct_utilisation_profile)
.sel(name=nodes)
ptes_supp_data.sel(name=heat_nodes)
.to_pandas()
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.

Same.

Comment on lines +3290 to +3297
p_max_source_raw = pd.read_csv(
heat_source_profile_files[heat_source],
index_col=0,
parse_dates=True,
).squeeze()[nodes]
).squeeze()

# if only dimension is nodes, convert series to dataframe with columns as nodes and index as snapshots
p_max_source = p_max_source_raw[heat_nodes]

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.

Same.

: -len(" urban central heat")
]
# Map subnodes to parent clusters for resource bus lookups
if "parent_node" in district_heat_info.columns:
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.

This simply checks if subnodes are enabled, correct? If yes, could we pass sector:district_heating:subnodes:enable here and in other places, where we're checking for that? Seems more readable.

Comment on lines 4604 to -4608
@@ -4601,12 +4626,6 @@ def add_industry(
# 1e6 to convert TWh to MWh
industrial_demand = pd.read_csv(industrial_demand_file, index_col=0) * 1e6 * nyears

if not options["biomass"]:
raise ValueError(
"Industry demand includes solid biomass, but `sector.biomass` is disabled. "
"Enable `sector: {biomass: true}` in config."
)
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.

Correct to remove, but should then be checked in the validator.

Comment on lines +5026 to +5033
lt_heat_nodes = [
node
for node in industrial_demand.index
if node + " urban central heat" in n.buses.index
or node + " services urban decentral heat" in n.buses.index
]
lt_heat_nodes = pd.Index(lt_heat_nodes)

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.

Isn't this just heat_nodes?

Comment on lines +5505 to +5507
ft_nodes = urban_central[
(urban_central + " Fischer-Tropsch").isin(n.links.index)
]
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.

ft_nodes (as, e.g. sabatier_nodes) seem to be on parent-node level. Shouldn't we use heat_nodes here?

Comment on lines +246 to +247
demand_column = snakemake.params.get("demand_column", "Dem_GWh")
label_column = snakemake.params.get("label_column", "Label")
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.

Let's make those module level constants rather than config settings (not really something users would change).

Comment on lines +249 to +255
if subnode_countries:
invalid = set(subnode_countries) - set(countries)
if invalid:
logger.warning(f"Invalid subnode_countries {invalid} ignored")
effective_subnode_countries = [c for c in subnode_countries if c in countries]
else:
effective_subnode_countries = countries
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.

Let's move this to the config validator

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.

3 participants