Skip to content

feat: restructure and improve transmission topology + config for all carriers#2153

Open
bobbyxng wants to merge 29 commits into
PyPSA:masterfrom
resilient-project:new-transmission
Open

feat: restructure and improve transmission topology + config for all carriers#2153
bobbyxng wants to merge 29 commits into
PyPSA:masterfrom
resilient-project:new-transmission

Conversation

@bobbyxng
Copy link
Copy Markdown
Collaborator

Closes #2151

Changes proposed in this Pull Request

  • Added a dedicated transmission candidate build rule with carrier wildcard support
  • Implemented graph-theoretical topology for investment candidates in new build_transmission.py
    • Delaunay triangulation for candidate edge generation
    • Optional Gabriel filtering
    • Optional minimum degree backfilling, to mitigate under-connectedness of nodes.
    • Minimum topology when minimum degree is set to 1 and Gabriel filtering is enabled. Maximum topology is determined by Gabriel filtering disabled (Delaunay graph is the maximum topology of candidates)
  • Carrier-specific transmission settings in config and updated schema
  • Added new transmission docs section in configuration.rst
  • Note that currently, the PR scopes the candidate generation for hydrogen and carbon_dioxide. Will be updated to move all old keys into the new structure

Motivation

  • Current pipeline candidate generation around AC topology can miss intuitive shortest-path graph candidates.
  • Following discussion in Restructure and improve pipeline candidate topology (config + methodology) #2151, "legacy" topology built on AC grid topology is dropped, switching to new approach entirely
  • In the long run, brownfield/planned projects can be added for links of all carrier types (not only AC/DC) using this updated structure

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.
  • New rules are documented in the appropriate doc/*.rst files.

…itial build_transmission script using Delaunay and Gabriel filtering.
@bobbyxng bobbyxng self-assigned this Apr 17, 2026
@bobbyxng bobbyxng marked this pull request as draft April 17, 2026 13:26
@bobbyxng
Copy link
Copy Markdown
Collaborator Author

bobbyxng commented Apr 17, 2026

Both new methods yield roughly the same ballpark wrt. number of edges in a 200-node network:
image

Applying the Gabriel filter returns the minimum number of edges obtained through this method. The minimum degree of nodes is set to 1. By changing the degree, the topology can be increased again. So we are always moving in the range n_edges_gabriel <= n <= n_edges_delaunay @fneum

If you wanna get rid of "stubs", you can simply set the minimum degree to 2 or higher

@bobbyxng bobbyxng requested a review from fneum April 21, 2026 12:54
@bobbyxng bobbyxng marked this pull request as ready for review April 21, 2026 12:54
@bobbyxng
Copy link
Copy Markdown
Collaborator Author

I consolidated old and new keys into the setup below and updated the associated doc entries as well as config examples (please double-check) @fneum

I was not sure how to handle these settings, which are partly related to the transmission infrastructure in sector, including

  • electricity_distribution_grid: true
  • electricity_distribution_grid_cost_factor: 1.0
  • electricity_grid_connection: true
  • transmission_efficiency
  • H2_retrofit
  • H2_retrofit_capacity_per_CH4
  • gas_network_connectivity_upgrade
  • gas_distribution_grid
  • gas_distribution_grid_cost_factor: 1.0

Maybe some of those keys can also be moved to the respective transmission carrier, happy to receive your feedback here @fneum

# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission
transmission:
  electricity:
    enable: true
    base_network: osm
    transmission_limit: vopt
    lines:
      types:
        63.0: "94-AL1/15-ST1A 20.0"
        66.0: "94-AL1/15-ST1A 20.0"
        90.0: "184-AL1/30-ST1A 110.0"
        110.0: "184-AL1/30-ST1A 110.0"
        132.0: "243-AL1/39-ST1A 110.0"
        150.0: "243-AL1/39-ST1A 110.0"
        220.0: "Al/St 240/40 2-bundle 220.0"
        300.0: "Al/St 240/40 3-bundle 300.0"
        330.0: "Al/St 240/40 3-bundle 300.0"
        380.0: "Al/St 240/40 4-bundle 380.0"
        400.0: "Al/St 240/40 4-bundle 380.0"
        500.0: "Al/St 240/40 4-bundle 380.0"
        750.0: "Al/St 560/50 4-bundle 750.0"
      s_max_pu: 0.7
      s_nom_max: .inf
      max_extension: 20000
      length_factor: 1.25
      reconnect_crimea: true
      under_construction: keep
      dynamic_line_rating:
        activate: false
        cutout: default
        correction_factor: 0.95
        max_voltage_difference: false
        max_line_rating: false
    links:
      p_max_pu: 1.0
      p_min_pu: -1.0
      p_nom_max: .inf
      max_extension: 30000
      length_factor: 1.25
      under_construction: keep
    transformers:
      x: 0.1
      s_nom: 2000.0
      type: ""
    projects:
      enable: true
      include:
        tyndp2020: true
        nep: true
        manual: true
      skip:
      - upgraded_lines
      - upgraded_links
      status:
      - under_construction
      - in_permitting
      - confirmed
      new_link_capacity: zero
  hydrogen:
    enable: true
    gabriel_filter:
      enable: true
      min_degree: 1
    length_factor: 1.25
    cost_factor: 1
  carbon_dioxide:
    enable: true
    gabriel_filter:
      enable: true
      min_degree: 1
    length_factor: 1.25
    cost_factor: 1
  methane_gas:
    enable: true

@bobbyxng
Copy link
Copy Markdown
Collaborator Author

bobbyxng commented Apr 21, 2026

Also renamed "gas" to "methane_gas" for now, open to suggestions, technically we should probably call it methane to be in alignment with the other carriers (Edit: renamed it back to gas)

Comment thread rules/build_sector.smk Outdated
Comment thread rules/build_sector.smk Outdated
@bobbyxng
Copy link
Copy Markdown
Collaborator Author

Added a key that allows to set a maximum offshore (haversine) distance, similar to min_degree. This gives a bit more fine-grained control for offshore connections that the user deems unreasonable upfront

Here's an example with an arbitrary maximum haversine distance of 300 km offshore
image

@bobbyxng
Copy link
Copy Markdown
Collaborator Author

bobbyxng commented Apr 24, 2026

I believe I finalised everything now. All transmission related settings are consolidated in the new transmission key.
Note that I deliberately left these keys in sector and added a _cost suffix, as they do not actually add the _distribution_grid but only capital costs to generators.

sector:
  electricity_grid_connection_cost: true
  gas_distribution_grid_cost: true
  gas_distribution_grid_cost_factor: 1.0

New setup:

# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission
transmission:
  electricity:
    enable: true
    base_network: osm
    transmission_limit: vopt
    lines:
      types:
        63.0: "94-AL1/15-ST1A 20.0"
        66.0: "94-AL1/15-ST1A 20.0"
        90.0: "184-AL1/30-ST1A 110.0"
        110.0: "184-AL1/30-ST1A 110.0"
        132.0: "243-AL1/39-ST1A 110.0"
        150.0: "243-AL1/39-ST1A 110.0"
        220.0: "Al/St 240/40 2-bundle 220.0"
        300.0: "Al/St 240/40 3-bundle 300.0"
        330.0: "Al/St 240/40 3-bundle 300.0"
        380.0: "Al/St 240/40 4-bundle 380.0"
        400.0: "Al/St 240/40 4-bundle 380.0"
        500.0: "Al/St 240/40 4-bundle 380.0"
        750.0: "Al/St 560/50 4-bundle 750.0"
      s_max_pu: 0.7
      s_nom_max: .inf
      max_extension: 20000
      length_factor: 1.25
      reconnect_crimea: true
      under_construction: keep
      dynamic_line_rating:
        activate: false
        cutout: default
        correction_factor: 0.95
        max_voltage_difference: false
        max_line_rating: false
    links:
      p_max_pu: 1.0
      p_min_pu: -1.0
      p_nom_max: .inf
      max_extension: 30000
      length_factor: 1.25
      under_construction: keep
      efficiency:
        enable: true
        efficiency_static: 0.98
        efficiency_per_1000km: 0.977
    transformers:
      x: 0.1
      s_nom: 2000.0
      type: ""
    projects:
      enable: true
      include:
        tyndp2020: true
        nep: true
        manual: true
      skip:
      - upgraded_lines
      - upgraded_links
      status:
      - under_construction
      - in_permitting
      - confirmed
      new_link_capacity: zero
  electricity_distribution:
    enable: true
    cost_factor: 1.0
    efficiency:
      enable: true
      efficiency_static: 0.97
  hydrogen:
    enable: true
    gabriel_filter:
      enable: true
      min_degree: 1
    max_offshore_haversine_distance: .inf
    length_factor: 1.25
    cost_factor: 1
    retrofit:
      enable: false
      capacity_per_ch4: 0.6
    efficiency:
      enable: true
      efficiency_per_1000km: 1.0
      compression_per_1000km: 0.018
  carbon_dioxide:
    enable: true
    gabriel_filter:
      enable: true
      min_degree: 1
    max_offshore_haversine_distance: .inf
    length_factor: 1.25
    cost_factor: 1
  gas:
    enable: true
    connectivity_upgrade: 1
    efficiency:
      enable: true
      efficiency_per_1000km: 1.0
      compression_per_1000km: 0.01

@bobbyxng
Copy link
Copy Markdown
Collaborator Author

Ready for (final) review @fneum @brynpickering

Copy link
Copy Markdown
Contributor

@brynpickering brynpickering left a comment

Choose a reason for hiding this comment

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

Thanks for the updates @bobbyxng . As you might notice, I'm not really reviewing your implementation. I trust that it works as you expect it to. Instead, I'm concerned with maintainability and user-facing (i.e. config). @fneum might be more qualified to actually dissect the delaunay and filtering methods

Comment thread rules/build_sector.smk Outdated
Comment thread rules/build_sector.smk
Comment on lines +227 to +231
max_offshore_haversine_distance: float = Field(
float("inf"),
gt=0,
description="Maximum haversine distance in km for offshore transmission candidates. Candidate edges a total offshore length exceeding this threshold are excluded. Defaults to infinity (no limit).",
)
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.

move to within gabriel_filter, perhaps by renaming to filter_edges and having gabriel_filter_min_degree and max_offshore_haversine_distance within it (since the max_offshore distance is currently only being triggered when the gabriel filter is also active).

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.

Setting gabriel_filter_min_degree default to zero will effectively switch it off, so if a user wants to activate it, they can set it to >= 1. Then you have the enable key which defines whether filtering should be attempted and then two keys that default to effectively not being applied unless a user changes the values (< inf for haversine, > 0 for gabriel).

This is arguably overkill, you could even avoid the enable key since they'd default to not being applied anyway, but this approach leaves open the option to add further filtering methods in future and switch them on/off as a group as desired.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point, I have also changed the config now so the user can choose to limit the candidates based on max_offshore_haversine_distance without using the gabriel filter.

Example

  carbon_dioxide:
    enable: true
    gabriel_filter_min_degree: 0
    max_offshore_haversine_distance: 700 # km

Comment thread rules/build_sector.smk Outdated
Comment on lines +1627 to +1642
for carrier, carrier_cfg in config.get("transmission", {}).items():
if not isinstance(carrier_cfg, dict) or not carrier_cfg.get("enable", False):
continue
gabriel_cfg = carrier_cfg.get("gabriel_filter")
if not isinstance(gabriel_cfg, dict):
continue
key = f"{carrier}_transmission_candidates"
if gabriel_cfg.get("enable", False) and "min_degree" in gabriel_cfg:
min_degree = int(gabriel_cfg["min_degree"])
max_offdist = f"{float(carrier_cfg.get('max_offshore_haversine_distance', float('inf'))):g}"
candidates[key] = resources(
f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson"
)
else:
candidates[key] = resources("transmission/all_edges_{clusters}.geojson")
return candidates
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 is overly cautious given what is being enforced in by the config validation. Transmission must be a dict with all carrier keys defined. min_degree and max_offshore will also be int/float respectively as that is forced by the validator.

Suggested change
for carrier, carrier_cfg in config.get("transmission", {}).items():
if not isinstance(carrier_cfg, dict) or not carrier_cfg.get("enable", False):
continue
gabriel_cfg = carrier_cfg.get("gabriel_filter")
if not isinstance(gabriel_cfg, dict):
continue
key = f"{carrier}_transmission_candidates"
if gabriel_cfg.get("enable", False) and "min_degree" in gabriel_cfg:
min_degree = int(gabriel_cfg["min_degree"])
max_offdist = f"{float(carrier_cfg.get('max_offshore_haversine_distance', float('inf'))):g}"
candidates[key] = resources(
f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson"
)
else:
candidates[key] = resources("transmission/all_edges_{clusters}.geojson")
return candidates
for carrier, carrier_cfg in config["transmission"].items():
gabriel_cfg = carrier_cfg.get("gabriel_filter")
if ("gabriel_filter" := carrier_cfg.get("gabriel_filter")) is None or not carrier_cfg["enable"]:
continue
key = f"{carrier}_transmission_candidates"
if gabriel_cfg["enable"]:
min_degree = gabriel_cfg["min_degree"]
max_offdist = carrier_cfg["max_offshore_haversine_distance"]
candidates[key] = resources(
f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson"
)
else:
candidates[key] = resources("transmission/all_edges_{clusters}.geojson")
return candidates

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.

btw, I don't like that the existence of the gabriel_filter key implicitly defines that the network will be defined by delaunay triangulation. That is, there's nothing explicit in the config to suggest how the edges will be generated, except for electricity which has base_network. Could we perhaps have base_network as a config option for every transmission carrier, then limit it to delaunay for hydrogen and co2, osm(?) for gas. Would leave open the option to extend the base network options in future (e.g. using only existing gas network for h2)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Implemented the first comment.
Regarding the second, had similar thoughts on this, however, without a clear conclusion yet on this:

  • @fneum suggested to drop the "legacy" option to generate alternative topologies for H2 and CO2, i.e., from the electricity high voltage grid (there is no really good reason keeping the old approach)
  • If we were to include base_network, we could either limit the options to delaunay for transmission.hydrogen.base_network and transmission.carbon_dioxide.base_network or also allow electricity_topology if we still want to enable the original behaviour of the current master branch
  • Regarding using only existing gas network for H2, there is some overlap with using transmission.hydrogen.retrofit, which does not fully mean the same thing as what you are suggesting, might introduce a bit of confusion though.
  • I generally like the idea to have base_network for each transmission carrier, even if for now, the options would be limited.

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.

I generally like the idea to have base_network for each transmission carrier, even if for now, the options would be limited.

Yeah, I'd also be fine with it even if it just has delaunay for now (or also the electricity_topology for backwards-compatibility, but then you need the logic to pull that topology when requested, so maybe not worth it).

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.

Regarding using only existing gas network for H2, there is some overlap with using transmission.hydrogen.retrofit, which does not fully mean the same thing as what you are suggesting, might introduce a bit of confusion though.

Yeah, true. It would be neat to be able to define base_network: gas_topology and then retrofit could be used to define whether it can use the existing base_network or has to build from scratch following the same topology. Maybe not something for now.

Comment thread scripts/build_transmission_topology.py Outdated
Comment thread scripts/prepare_sector_network.py Outdated
buses_prev, lines_prev, links_prev = len(n.buses), len(n.lines), len(n.links)

linetype_380 = snakemake.config["lines"]["types"][380]
linetype_380 = snakemake.config["transmission"]["electricity"]["lines"]["types"][
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 is the opposite of my earlier comment: there's no requirement from the validator for the user to define the 380kV line type. They could replace the line types dict and not include this level. This call will then fail. Since it's a critical level, I would add a field validator to the validator to ensure the 380 key is always defined (perhaps beyond the scope of this PR, open an issue if so).

Comment thread scripts/build_transmission_topology.py Outdated
Comment thread scripts/build_transmission_topology.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restructure and improve pipeline candidate topology (config + methodology)

2 participants