From 42e2dbd9c95ce85bf14068a8bd5e16671ea4ca96 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 17 Apr 2026 15:17:14 +0200 Subject: [PATCH 01/32] Added new transmission key to config, schema and docs. Implemented initial build_transmission script using Delaunay and Gabriel filtering. --- config/config.default.yaml | 15 + config/schema.default.json | 186 +++++++ doc/configuration.rst | 22 + rules/build_sector.smk | 41 ++ scripts/build_transmission.py | 477 ++++++++++++++++++ scripts/lib/validation/config/_schema.py | 5 + scripts/lib/validation/config/transmission.py | 56 ++ 7 files changed, 802 insertions(+) create mode 100644 scripts/build_transmission.py create mode 100644 scripts/lib/validation/config/transmission.py diff --git a/config/config.default.yaml b/config/config.default.yaml index 146429bbc1..7766263e0e 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -184,6 +184,21 @@ electricity: by_country: false transmission_limit: vopt +# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission +transmission: + hydrogen: + enable: true + gabriel_filter: + enable: true + min_degree: 1 + length_factor: 1.25 + carbon_dioxide: + enable: true + gabriel_filter: + enable: true + min_degree: 1 + length_factor: 1.25 + # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite atlite: default_cutout: "europe-2013-sarah3-era5" diff --git a/config/schema.default.json b/config/schema.default.json index 0468c33193..966bffefed 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5603,6 +5603,75 @@ } } }, + "TransmissionConfig": { + "description": "Configuration for `transmission` settings.", + "properties": { + "hydrogen": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "carbon_dioxide": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" + } + } + } + } + }, "TransmissionProjectsConfig": { "description": "Configuration for `transmission_projects` settings.", "properties": { @@ -6611,6 +6680,22 @@ } } }, + "_GabrielFilterConfig": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, "_HydroConfig": { "description": "Configuration for hydropower.", "properties": { @@ -8066,6 +8151,38 @@ } } }, + "_TransmissionCarrierConfig": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, "_TransmissionEfficiencyConfig": { "description": "Configuration for `sector.transmission_efficiency` settings.", "properties": { @@ -8768,6 +8885,75 @@ } } }, + "transmission": { + "description": "Configuration for `transmission` settings.", + "properties": { + "hydrogen": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "carbon_dioxide": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } + }, + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" + } + } + } + } + }, "atlite": { "description": "Configuration for `atlite` settings.", "properties": { diff --git a/doc/configuration.rst b/doc/configuration.rst index ae4bfb2cdc..7f7e532ab4 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -288,6 +288,28 @@ Switches for some rules and optional features. :start-at: electricity: :end-before: # docs +.. _transmission_cf: + +``transmission`` +================ + +Configure transmission related settings for all carriers. + +For non electric carriers, the transmission candidates are built based on +Delaunay triangulation and optional Gabriel filtering. The minimum degree +specifies the minimum connectedness of the candidate graph. + +.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: transmission: + :end-before: # docs + .. _atlite_cf: ``atlite`` diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 59b6540fea..8187a25571 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -146,6 +146,47 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") +rule build_transmission: + message: + "Building transmission candidates for {wildcards.carrier} and {wildcards.clusters} clusters" + wildcard_constraints: + carrier="hydrogen|carbon_dioxide", + params: + carrier_enabled=lambda w: config_provider( + "transmission", w.carrier, "enable" + )(w), + gabriel_filter_enabled=lambda w: config_provider( + "transmission", + w.carrier, + "gabriel_filter", + "enable", + )(w), + min_degree=lambda w: config_provider( + "transmission", + w.carrier, + "gabriel_filter", + "min_degree", + )(w), + length_factor=lambda w: config_provider( + "transmission", + w.carrier, + "length_factor", + )(w), + input: + network=resources("networks/base_s_{clusters}.nc"), + output: + all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), + candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), + resources: + mem_mb=4000, + log: + logs("build_transmission_{carrier}_{clusters}.log"), + benchmark: + benchmarks("build_transmission/{carrier}_{clusters}") + script: + scripts("build_transmission.py") + + rule build_daily_heat_demand: input: pop_layout=resources("pop_layout_total.nc"), diff --git a/scripts/build_transmission.py b/scripts/build_transmission.py new file mode 100644 index 0000000000..a06883ef96 --- /dev/null +++ b/scripts/build_transmission.py @@ -0,0 +1,477 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Build transmission corridor topology/candidates from clustered network +bus coordinates using Delaunay triangulation and optional Gabriel graph +filtering. + +Description +----------- +Creates the topology for potential investment corridors between +clustered buses, i.e. for H2 and CO2 pipeline candidates. + +The script computes the Delaunay triangulation of the bus coordinates +and optionally filters edges to retain only Gabriel edges, which are +more likely to represent direct connections. A minimum degree +constraint can be enforced to ensure network connectivity and to avoid +stubs. + +""" + +from __future__ import annotations + +import logging + +import geopandas as gpd +import numpy as np +import pandas as pd +import pypsa +from pypsa.geo import haversine_pts +from numpy.typing import NDArray +from scipy.spatial import Delaunay, QhullError +from shapely.geometry import LineString + +from scripts._helpers import configure_logging, set_scenario_config + +logger = logging.getLogger(__name__) + +GEO_CRS = "EPSG:4326" +DISTANCE_CRS = "EPSG:3035" +COLS_EDGES = [ + "name", + "bus0", + "bus1", + "length", + "gabriel_edge", + "geometry", +] + + +def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: + """Build sorted unique undirected edges from Delaunay simplices. + + Parameters + ---------- + coords_meter : NDArray[np.float64] + Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. + + Returns + ------- + list[tuple[int, int]] + Sorted list of unique undirected edges represented as index pairs + ``(u, v)`` with ``u < v``. + + Raises + ------ + ValueError + If fewer than three points are provided. + """ + if len(coords_meter) < 3: + raise ValueError("Need at least 3 points to compute Delaunay triangulation.") + + try: + tri = Delaunay(coords_meter) + except QhullError: + # Robust fallback for nearly degenerate point sets. + tri = Delaunay(coords_meter, qhull_options="QJ") + + edges: set[tuple[int, int]] = set() + for simplex in tri.simplices: + for i, j in ((0, 1), (1, 2), (0, 2)): + edges.add(tuple(sorted((int(simplex[i]), int(simplex[j]))))) + return sorted(edges) + + +def pairwise_sq_dists(coords: NDArray[np.float64]) -> NDArray[np.float64]: + """Compute the squared Euclidean distance matrix. + + Parameters + ---------- + coords : NDArray[np.float64] + Node coordinates in Euclidean space with shape ``(n_nodes, n_dims)``. + + Returns + ------- + NDArray[np.float64] + Dense matrix ``D`` with shape ``(n_nodes, n_nodes)`` where + ``D[i, j] = ||coords[i] - coords[j]||^2``. + """ + norms = np.sum(coords * coords, axis=1) + sq = norms[:, None] + norms[None, :] - 2.0 * (coords @ coords.T) + return np.maximum(sq, 0.0) + + +def classify_gabriel_edges( + edges: NDArray[np.int64], + coords_meter: NDArray[np.float64], + eps: float = 1e-9, + chunk_size: int = 1024, +) -> NDArray[np.bool_]: + """Classify candidate edges as Gabriel or non-Gabriel. + + Parameters + ---------- + edges : NDArray[np.int64] + Candidate undirected edges as node index pairs with shape ``(n_edges, 2)``. + coords_meter : NDArray[np.float64] + Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. + eps : float, default 1e-9 + Numerical tolerance used in the Gabriel emptiness test. + chunk_size : int, default 1024 + Batch size used to limit temporary memory during vectorized checks. + + Returns + ------- + NDArray[np.bool_] + Boolean mask of shape ``(n_edges,)`` where ``True`` marks Gabriel edges. + """ + sq_dists = pairwise_sq_dists(coords_meter) + edge_count = edges.shape[0] + is_gabriel = np.ones(edge_count, dtype=bool) + + for start in range(0, edge_count, chunk_size): + end = min(start + chunk_size, edge_count) + uv = edges[start:end] + u = uv[:, 0] + v = uv[:, 1] + dij2 = sq_dists[u, v] + + # A point k invalidates edge (u, v) when dik^2 + djk^2 < dij^2. + inside = (sq_dists[u, :] + sq_dists[v, :]) < (dij2[:, None] - eps) + row = np.arange(end - start) + inside[row, u] = False + inside[row, v] = False + is_gabriel[start:end] = ~inside.any(axis=1) + + return is_gabriel + + +def get_bus_coordinates( + n: pypsa.Network, +) -> tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]]: + """Extract bus ids and coordinates in geographic and metric CRS. + + Parameters + ---------- + n : pypsa.Network + Input network containing bus coordinate columns ``x`` and ``y``. + + Returns + ------- + tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]] + Tuple ``(bus_ids, coords_geo, coords_meter)`` where ``coords_geo`` is in + ``EPSG:4326`` and ``coords_meter`` is in ``EPSG:3035``. + + Raises + ------ + ValueError + If fewer than two buses with valid coordinates are available. + """ + buses = n.buses.copy() + buses = buses.dropna(subset=["x", "y"]) + + if len(buses) < 2: + raise ValueError("Need at least 2 buses with coordinates to build corridors.") + + points_geo = gpd.points_from_xy(buses["x"], buses["y"], crs=GEO_CRS) + points_meter = gpd.GeoSeries(points_geo, crs=GEO_CRS).to_crs(DISTANCE_CRS) + + coords_geo = np.column_stack( + (np.asarray(points_geo.x), np.asarray(points_geo.y)) + ).astype(float) + coords_meter = np.column_stack( + (np.asarray(points_meter.x), np.asarray(points_meter.y)) + ).astype(float) + + return buses.index, coords_geo, coords_meter + + +def delaunay_triangulation( + bus_ids: pd.Index, + coords_geo: NDArray[np.float64], + coords_meter: NDArray[np.float64], + length_factor: float = 1.25, +) -> gpd.GeoDataFrame: + """Build the full Delaunay edge table with geometry and metadata. + + Parameters + ---------- + bus_ids : pd.Index + Bus identifiers aligned with the coordinate arrays. + coords_geo : NDArray[np.float64] + Geographic coordinates (longitude, latitude) in ``EPSG:4326``. + coords_meter : NDArray[np.float64] + Projected coordinates in ``EPSG:3035`` used for metric calculations. + + Returns + ------- + gpd.GeoDataFrame + Delaunay edge table including integer node endpoints, normalized bus + names, canonical edge ``name``, ``length`` (haversine distance, km), + ``gabriel_edge`` flag, and LineString geometry. + """ + + edges = delaunay_edges(coords_meter) + edges_arr = np.array(edges, dtype=np.int64) + u = edges_arr[:, 0] + v = edges_arr[:, 1] + + logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) + logger.info("Calculating edge lengths using length factor %.2f.", length_factor) + lengths = haversine_pts(coords_geo[u], coords_geo[v])*length_factor + gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) + edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] + + bus0 = bus_ids.take(u).astype(str).to_numpy() + bus1 = bus_ids.take(v).astype(str).to_numpy() + + # Keep undirected edge naming compatible with topology naming in + # prepare_sector_network.create_network_topology. + swap_mask = bus0 > bus1 + bus0_norm = np.where(swap_mask, bus1, bus0) + bus1_norm = np.where(swap_mask, bus0, bus1) + connector = " -> " + edge_names = np.char.add( + np.char.add(bus0_norm, connector), + bus1_norm, + ) + + edges_gdf = gpd.GeoDataFrame( + { + "source": u, + "target": v, + "bus0": bus0_norm, + "bus1": bus1_norm, + "name": edge_names, + "length": lengths, + "gabriel_edge": gabriel_flags, + "geometry": edge_geoms, + }, + geometry="geometry", + crs=GEO_CRS, + ) + return edges_gdf + + +def enforce_min_degree( + selected_edges: gpd.GeoDataFrame, + delaunay_graph: gpd.GeoDataFrame, + node_count: int, + min_degree: int, +) -> gpd.GeoDataFrame: + """Add shortest Delaunay edges to satisfy a minimum node degree. + + Iterates over all Delaunay edges in ascending order of length and adds + edges to the selected set until every node reaches the requested minimum + degree, or until no further Delaunay edges are available for that node. + + Parameters + ---------- + selected_edges : gpd.GeoDataFrame + Currently selected candidate edges, e.g. after Gabriel filtering. + Must contain integer columns ``source``, ``target``, and ``length``. + delaunay_graph : gpd.GeoDataFrame + Full Delaunay triangulation edges used as the backfill pool. + Must contain integer columns ``source``, ``target``, and ``length``, + with canonical ordering ``source < target``. + node_count : int + Total number of nodes in the graph. Used to size degree arrays. + min_degree : int + Minimum number of edges required per node. Values <= 0 disable the + constraint and return ``selected_edges`` unchanged. + + Returns + ------- + gpd.GeoDataFrame + Edge GeoDataFrame with the same schema as ``selected_edges``, + extended with any backfill edges needed to meet the degree constraint. + Nodes whose maximum possible Delaunay degree is below ``min_degree`` + are handled gracefully with a warning. + + Warns + ----- + Logs a warning if the minimum degree cannot be fully satisfied, either + because a node has too few Delaunay neighbours (unreachable) or because + the greedy backfill did not converge (unmet after backfill). + """ + if min_degree <= 0: + logger.info("Min-degree constraint disabled (min_degree=%s).", min_degree) + return selected_edges + + result = selected_edges.copy() + all_sorted = delaunay_graph.sort_values("length").reset_index(drop=True) + source_arr = all_sorted["source"].to_numpy(dtype=int) + target_arr = all_sorted["target"].to_numpy(dtype=int) + + max_possible_degree = np.bincount( + np.concatenate([source_arr, target_arr]), minlength=node_count + ) + + sel_source = result["source"].to_numpy(dtype=int) + sel_target = result["target"].to_numpy(dtype=int) + edge_set: set[tuple[int, int]] = set(zip(sel_source, sel_target)) + degrees = np.bincount( + np.concatenate([sel_source, sel_target]), minlength=node_count + ) + + deficits = np.maximum(0, min_degree - degrees) + remaining_deficits = int(deficits.sum()) + additions_idx: list[int] = [] + + for idx, (u, v) in enumerate(zip(source_arr, target_arr)): + if remaining_deficits == 0: + break + if (u, v) in edge_set: + continue + if deficits[u] <= 0 and deficits[v] <= 0: + continue + edge_set.add((u, v)) + additions_idx.append(idx) + if deficits[u] > 0: + deficits[u] -= 1 + remaining_deficits -= 1 + if deficits[v] > 0: + deficits[v] -= 1 + remaining_deficits -= 1 + + if additions_idx: + to_add = all_sorted.iloc[additions_idx] + result = gpd.GeoDataFrame( + pd.concat([result, to_add], ignore_index=True), + geometry="geometry", + crs=delaunay_graph.crs, + ) + + result_source = result["source"].to_numpy(dtype=int) + result_target = result["target"].to_numpy(dtype=int) + achieved_degree = np.bincount( + np.concatenate([result_source, result_target]), minlength=node_count + ) + target_degree = np.minimum(min_degree, max_possible_degree) + unreachable_mask = max_possible_degree < min_degree + unmet_mask = achieved_degree < target_degree + + if np.any(unreachable_mask) or np.any(unmet_mask): + logger.warning( + "Min-degree not fully met: requested=%s, unreachable=%s/%s, unmet_after_backfill=%s/%s.", + min_degree, + int(unreachable_mask.sum()), + node_count, + int(unmet_mask.sum()), + node_count, + ) + + return result + + +def prepare_candidate_edges( + delaunay_graph: gpd.GeoDataFrame, + gabriel_filter_enabled: bool, + node_count: int, + min_degree: int, +) -> gpd.GeoDataFrame: + """Build selected candidate edges from a Delaunay edge table. + + Parameters + ---------- + delaunay_graph : gpd.GeoDataFrame + Full Delaunay edge table. + gabriel_filter_enabled : bool + Whether to restrict the initial selection to Gabriel edges. + node_count : int + Number of nodes used for degree accounting. + min_degree : int + Minimum per-node degree target used for optional backfilling. + + Returns + ------- + gpd.GeoDataFrame + Selected edge table after optional Gabriel filtering and min-degree + backfilling. + """ + if not gabriel_filter_enabled: + return delaunay_graph.copy() + + selected_edges = delaunay_graph[delaunay_graph["gabriel_edge"]].copy() + selected = enforce_min_degree( + selected_edges=selected_edges, + delaunay_graph=delaunay_graph, + node_count=node_count, + min_degree=min_degree, + ) + + return selected + + +def mark_selected_edges( + delaunay_graph: gpd.GeoDataFrame, + selected_edges: gpd.GeoDataFrame, +) -> gpd.GeoDataFrame: + """Annotate all Delaunay edges with selection membership. + + Parameters + ---------- + delaunay_graph : gpd.GeoDataFrame + Full Delaunay edge table to annotate. + selected_edges : gpd.GeoDataFrame + Selected candidate edges containing the ``name`` column. + + Returns + ------- + gpd.GeoDataFrame + ``delaunay_graph`` with an added boolean ``selected_edge`` column. + """ + selected_names = set(selected_edges["name"]) + delaunay_graph["selected_edge"] = delaunay_graph["name"].isin(selected_names) + return delaunay_graph + + +if __name__ == "__main__": + is_mock_run = "snakemake" not in globals() + + if is_mock_run: + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_transmission", + carrier="carbon_dioxide", + clusters="adm", + run="test", + configfiles=["config/config.nrw.yaml"] + ) + + configure_logging(snakemake) + set_scenario_config(snakemake) + + carrier_enabled = bool(snakemake.params["carrier_enabled"]) + gabriel_filter_enabled = bool(snakemake.params["gabriel_filter_enabled"]) + min_degree = int(snakemake.params["min_degree"]) + length_factor = float(snakemake.params["length_factor"]) + + if not carrier_enabled: + raise ValueError("Transmission carrier is disabled in config for this wildcard.") + + n = pypsa.Network(snakemake.input.network) + + # Delaunay triangulation and Gabriel filtering + bus_ids, coords_geo, coords_meter = get_bus_coordinates(n) + delaunay_graph = delaunay_triangulation( + bus_ids=bus_ids, + coords_geo=coords_geo, + coords_meter=coords_meter, + length_factor=length_factor, + ) + selected_edges = prepare_candidate_edges( + delaunay_graph=delaunay_graph, + gabriel_filter_enabled=gabriel_filter_enabled, + node_count=len(coords_geo), + min_degree=min_degree, + ) + selected_edges = selected_edges[COLS_EDGES] + delaunay_graph = mark_selected_edges(delaunay_graph, selected_edges) + + # Export + delaunay_graph.to_file(snakemake.output.all_edges) + selected_edges.to_file(snakemake.output.candidates) \ No newline at end of file diff --git a/scripts/lib/validation/config/_schema.py b/scripts/lib/validation/config/_schema.py index 5adb8665f0..7feb8b1a3c 100644 --- a/scripts/lib/validation/config/_schema.py +++ b/scripts/lib/validation/config/_schema.py @@ -36,6 +36,7 @@ from scripts.lib.validation.config.solar_thermal import SolarThermalConfig from scripts.lib.validation.config.solving import SolvingConfig from scripts.lib.validation.config.transformers import TransformersConfig +from scripts.lib.validation.config.transmission import TransmissionConfig from scripts.lib.validation.config.transmission_projects import ( TransmissionProjectsConfig, ) @@ -132,6 +133,10 @@ class ConfigSchema(BaseModel): default_factory=ElectricityConfig, description="Electricity sector configuration.", ) + transmission: TransmissionConfig = Field( + default_factory=TransmissionConfig, + description="Transmission candidate configuration.", + ) atlite: AtliteConfig = Field( default_factory=AtliteConfig, description="Atlite cutout configuration for weather data.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py new file mode 100644 index 0000000000..4e74b901d5 --- /dev/null +++ b/scripts/lib/validation/config/transmission.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Transmission candidate configuration. + +See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission +""" + +from pydantic import BaseModel, Field + + +class _GabrielFilterConfig(BaseModel): + """Configuration for `transmission..gabriel_filter` settings.""" + + enable: bool = Field( + True, + description="Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + ) + min_degree: int = Field( + 1, + ge=0, + description="Minimum node degree target applied after Gabriel filtering.", + ) + + +class _TransmissionCarrierConfig(BaseModel): + """Configuration for a single transmission carrier.""" + + enable: bool = Field( + True, + description="Enable transmission candidate generation for this carrier.", + ) + gabriel_filter: _GabrielFilterConfig = Field( + default_factory=_GabrielFilterConfig, + description="Gabriel filter configuration.", + ) + length_factor: float = Field( + 1.25, + gt=0, + description="Multiplier applied to geometric corridor lengths.", + ) + + +class TransmissionConfig(BaseModel): + """Configuration for `transmission` settings.""" + + hydrogen: _TransmissionCarrierConfig = Field( + default_factory=_TransmissionCarrierConfig, + description="Configuration for hydrogen transmission candidates.", + ) + carbon_dioxide: _TransmissionCarrierConfig = Field( + default_factory=_TransmissionCarrierConfig, + description="Configuration for carbon dioxide transmission candidates.", + ) From 44320a78def7fe9e5f7cb8b8c6ba3e49edf70476 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 17 Apr 2026 15:36:21 +0200 Subject: [PATCH 02/32] Fix pre-commit issues, trailing whitespaces, etc. --- rules/build_sector.smk | 32 +++++++++++----------- scripts/build_transmission.py | 50 +++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 8187a25571..a5641e61d5 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -147,14 +147,23 @@ rule cluster_gas_network: rule build_transmission: - message: - "Building transmission candidates for {wildcards.carrier} and {wildcards.clusters} clusters" + input: + network=resources("networks/base_s_{clusters}.nc"), + output: + all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), + candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), + log: + logs("build_transmission_{carrier}_{clusters}.log"), + benchmark: + benchmarks("build_transmission/{carrier}_{clusters}") wildcard_constraints: carrier="hydrogen|carbon_dioxide", + resources: + mem_mb=4000, params: - carrier_enabled=lambda w: config_provider( - "transmission", w.carrier, "enable" - )(w), + carrier_enabled=lambda w: config_provider("transmission", w.carrier, "enable")( + w + ), gabriel_filter_enabled=lambda w: config_provider( "transmission", w.carrier, @@ -172,17 +181,8 @@ rule build_transmission: w.carrier, "length_factor", )(w), - input: - network=resources("networks/base_s_{clusters}.nc"), - output: - all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), - candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), - resources: - mem_mb=4000, - log: - logs("build_transmission_{carrier}_{clusters}.log"), - benchmark: - benchmarks("build_transmission/{carrier}_{clusters}") + message: + "Building transmission candidates for {wildcards.carrier} and {wildcards.clusters} clusters" script: scripts("build_transmission.py") diff --git a/scripts/build_transmission.py b/scripts/build_transmission.py index a06883ef96..c7e1bc5664 100644 --- a/scripts/build_transmission.py +++ b/scripts/build_transmission.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT """ -Build transmission corridor topology/candidates from clustered network -bus coordinates using Delaunay triangulation and optional Gabriel graph +Build transmission corridor topology/candidates from clustered network +bus coordinates using Delaunay triangulation and optional Gabriel graph filtering. Description @@ -12,10 +12,10 @@ Creates the topology for potential investment corridors between clustered buses, i.e. for H2 and CO2 pipeline candidates. -The script computes the Delaunay triangulation of the bus coordinates -and optionally filters edges to retain only Gabriel edges, which are -more likely to represent direct connections. A minimum degree -constraint can be enforced to ensure network connectivity and to avoid +The script computes the Delaunay triangulation of the bus coordinates +and optionally filters edges to retain only Gabriel edges, which are +more likely to represent direct connections. A minimum degree +constraint can be enforced to ensure network connectivity and to avoid stubs. """ @@ -28,8 +28,8 @@ import numpy as np import pandas as pd import pypsa -from pypsa.geo import haversine_pts from numpy.typing import NDArray +from pypsa.geo import haversine_pts from scipy.spatial import Delaunay, QhullError from shapely.geometry import LineString @@ -50,7 +50,8 @@ def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: - """Build sorted unique undirected edges from Delaunay simplices. + """ + Build sorted unique undirected edges from Delaunay simplices. Parameters ---------- @@ -85,7 +86,8 @@ def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: def pairwise_sq_dists(coords: NDArray[np.float64]) -> NDArray[np.float64]: - """Compute the squared Euclidean distance matrix. + """ + Compute the squared Euclidean distance matrix. Parameters ---------- @@ -109,7 +111,8 @@ def classify_gabriel_edges( eps: float = 1e-9, chunk_size: int = 1024, ) -> NDArray[np.bool_]: - """Classify candidate edges as Gabriel or non-Gabriel. + """ + Classify candidate edges as Gabriel or non-Gabriel. Parameters ---------- @@ -151,7 +154,8 @@ def classify_gabriel_edges( def get_bus_coordinates( n: pypsa.Network, ) -> tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]]: - """Extract bus ids and coordinates in geographic and metric CRS. + """ + Extract bus ids and coordinates in geographic and metric CRS. Parameters ---------- @@ -194,7 +198,8 @@ def delaunay_triangulation( coords_meter: NDArray[np.float64], length_factor: float = 1.25, ) -> gpd.GeoDataFrame: - """Build the full Delaunay edge table with geometry and metadata. + """ + Build the full Delaunay edge table with geometry and metadata. Parameters ---------- @@ -209,7 +214,7 @@ def delaunay_triangulation( ------- gpd.GeoDataFrame Delaunay edge table including integer node endpoints, normalized bus - names, canonical edge ``name``, ``length`` (haversine distance, km), + names, canonical edge ``name``, ``length`` (haversine distance, km), ``gabriel_edge`` flag, and LineString geometry. """ @@ -220,7 +225,7 @@ def delaunay_triangulation( logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) logger.info("Calculating edge lengths using length factor %.2f.", length_factor) - lengths = haversine_pts(coords_geo[u], coords_geo[v])*length_factor + lengths = haversine_pts(coords_geo[u], coords_geo[v]) * length_factor gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] @@ -261,7 +266,8 @@ def enforce_min_degree( node_count: int, min_degree: int, ) -> gpd.GeoDataFrame: - """Add shortest Delaunay edges to satisfy a minimum node degree. + """ + Add shortest Delaunay edges to satisfy a minimum node degree. Iterates over all Delaunay edges in ascending order of length and adds edges to the selected set until every node reaches the requested minimum @@ -372,7 +378,8 @@ def prepare_candidate_edges( node_count: int, min_degree: int, ) -> gpd.GeoDataFrame: - """Build selected candidate edges from a Delaunay edge table. + """ + Build selected candidate edges from a Delaunay edge table. Parameters ---------- @@ -409,7 +416,8 @@ def mark_selected_edges( delaunay_graph: gpd.GeoDataFrame, selected_edges: gpd.GeoDataFrame, ) -> gpd.GeoDataFrame: - """Annotate all Delaunay edges with selection membership. + """ + Annotate all Delaunay edges with selection membership. Parameters ---------- @@ -439,7 +447,7 @@ def mark_selected_edges( carrier="carbon_dioxide", clusters="adm", run="test", - configfiles=["config/config.nrw.yaml"] + configfiles=["config/config.nrw.yaml"], ) configure_logging(snakemake) @@ -451,7 +459,9 @@ def mark_selected_edges( length_factor = float(snakemake.params["length_factor"]) if not carrier_enabled: - raise ValueError("Transmission carrier is disabled in config for this wildcard.") + raise ValueError( + "Transmission carrier is disabled in config for this wildcard." + ) n = pypsa.Network(snakemake.input.network) @@ -474,4 +484,4 @@ def mark_selected_edges( # Export delaunay_graph.to_file(snakemake.output.all_edges) - selected_edges.to_file(snakemake.output.candidates) \ No newline at end of file + selected_edges.to_file(snakemake.output.candidates) From 3d1d3f522877d253314744b18a0a63abe2866f60 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 17 Apr 2026 16:21:50 +0200 Subject: [PATCH 03/32] Updated description to include detailed output/coluns. --- scripts/build_transmission.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/build_transmission.py b/scripts/build_transmission.py index c7e1bc5664..1dab5b09ae 100644 --- a/scripts/build_transmission.py +++ b/scripts/build_transmission.py @@ -18,6 +18,35 @@ constraint can be enforced to ensure network connectivity and to avoid stubs. +Outputs +------- +Two GeoJSON files in WGS84 (EPSG:4326) format are written per carrier and cluster configuration: + +1. ``all_edges`` + Full Delaunay edge table (one row per Delaunay edge) with columns: + + - ``name``: Canonical undirected edge identifier ``"bus0 -> bus1"``. + - ``bus0``: Canonically ordered first bus id (lexicographic order). + - ``bus1``: Canonically ordered second bus id. + - ``length``: Great-circle edge length in km multiplied by + ``length_factor``. + - ``gabriel_edge``: ``True`` if the edge satisfies the Gabriel empty + circle criterion. + - ``selected_edge``: ``True`` if the edge is part of the final + candidate set after filtering/backfilling. + - ``geometry``: LineString geometry in ``EPSG:4326``. + +2. ``candidates`` + Selected candidate corridor table (subset of Delaunay edges) with + columns: + + - ``name`` + - ``bus0`` + - ``bus1`` + - ``length`` + - ``gabriel_edge`` + - ``geometry`` + """ from __future__ import annotations @@ -445,9 +474,9 @@ def mark_selected_edges( snakemake = mock_snakemake( "build_transmission", carrier="carbon_dioxide", - clusters="adm", - run="test", - configfiles=["config/config.nrw.yaml"], + clusters="200", + run="nodes200", + configfiles=["config/config.200.yaml"], ) configure_logging(snakemake) From 65d1b0f5ae91acefe8a50ec8f76b85def07e2176 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 20 Apr 2026 10:40:49 +0200 Subject: [PATCH 04/32] renamed build_transmission to build_transmission_topology --- rules/build_sector.smk | 8 ++++---- ...ild_transmission.py => build_transmission_topology.py} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename scripts/{build_transmission.py => build_transmission_topology.py} (99%) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index a5641e61d5..de730fa930 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -146,16 +146,16 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") -rule build_transmission: +rule build_transmission_topology: input: network=resources("networks/base_s_{clusters}.nc"), output: all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), log: - logs("build_transmission_{carrier}_{clusters}.log"), + logs("build_transmission_topology_{carrier}_{clusters}.log"), benchmark: - benchmarks("build_transmission/{carrier}_{clusters}") + benchmarks("build_transmission_topology/{carrier}_{clusters}") wildcard_constraints: carrier="hydrogen|carbon_dioxide", resources: @@ -184,7 +184,7 @@ rule build_transmission: message: "Building transmission candidates for {wildcards.carrier} and {wildcards.clusters} clusters" script: - scripts("build_transmission.py") + scripts("build_transmission_topology.py") rule build_daily_heat_demand: diff --git a/scripts/build_transmission.py b/scripts/build_transmission_topology.py similarity index 99% rename from scripts/build_transmission.py rename to scripts/build_transmission_topology.py index 1dab5b09ae..60258a410a 100644 --- a/scripts/build_transmission.py +++ b/scripts/build_transmission_topology.py @@ -472,7 +472,7 @@ def mark_selected_edges( from scripts._helpers import mock_snakemake snakemake = mock_snakemake( - "build_transmission", + "build_transmission_topology", carrier="carbon_dioxide", clusters="200", run="nodes200", From a908726e6735d965f52b963c67882506435be2cb Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 20 Apr 2026 14:45:06 +0200 Subject: [PATCH 05/32] Implemented new config for H2 pipelines. --- Snakefile | 2 +- config/config.default.yaml | 1 - config/schema.default.json | 10 ------- rules/build_sector.smk | 6 ++++ scripts/_helpers.py | 2 +- scripts/build_transmission_topology.py | 23 +++++++++++++-- scripts/lib/validation/config/sector.py | 1 - scripts/prepare_sector_network.py | 39 +++++++++++++++++-------- 8 files changed, 55 insertions(+), 29 deletions(-) diff --git a/Snakefile b/Snakefile index bffdc038a9..8e18e8756c 100644 --- a/Snakefile +++ b/Snakefile @@ -117,7 +117,7 @@ rule all: ( RESULTS + "maps/static/base_s_{clusters}_{opts}_{sector_opts}-h2_network_{planning_horizons}.pdf" - if config_provider("sector", "H2_network")(w) + if config_provider("transmission", "hydrogen", "enable")(w) else [] ), run=config["run"]["name"], diff --git a/config/config.default.yaml b/config/config.default.yaml index 7766263e0e..9ec9a9c7f0 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -936,7 +936,6 @@ sector: compression_per_1000km: 0.01 electricity distribution grid: efficiency_static: 0.97 - H2_network: true gas_network: true H2_retrofit: false H2_retrofit_capacity_per_CH4: 0.6 diff --git a/config/schema.default.json b/config/schema.default.json index 966bffefed..4c34bdbc7e 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4889,11 +4889,6 @@ } } }, - "H2_network": { - "default": true, - "description": "Add option for new hydrogen pipelines.", - "type": "boolean" - }, "gas_network": { "default": true, "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", @@ -11519,11 +11514,6 @@ } } }, - "H2_network": { - "default": true, - "description": "Add option for new hydrogen pipelines.", - "type": "boolean" - }, "gas_network": { "default": true, "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", diff --git a/rules/build_sector.smk b/rules/build_sector.smk index de730fa930..c5051bc014 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1609,6 +1609,11 @@ rule prepare_sector_network: unpack(input_heat_source_power), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, + h2_transmission_candidates=lambda w: ( + resources("transmission/hydrogen_candidates_{clusters}.geojson") + if config_provider("transmission", "hydrogen", "enable")(w) + else [] + ), snapshot_weightings=resources( "snapshot_weightings_base_s_{clusters}_elec_{opts}_{sector_opts}.csv" ), @@ -1778,6 +1783,7 @@ rule prepare_sector_network: temperature_limited_stores=config_provider( "sector", "district_heating", "temperature_limited_stores" ), + h2_transmission_enable=config_provider("transmission", "hydrogen", "enable"), message: "Preparing integrated sector-coupled energy network for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizon, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: diff --git a/scripts/_helpers.py b/scripts/_helpers.py index 4c74e4b718..d316d3ed22 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -795,7 +795,7 @@ def update_config_from_wildcards(config, w, inplace=True): config["sector"]["electricity_transmission_grid"] = False if "noH2network" in opts: - config["sector"]["H2_network"] = False + config["transmission"]["hydrogen"] = False if "nowasteheat" in opts: config["sector"]["use_fischer_tropsch_waste_heat"] = False diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index 60258a410a..a0e1761828 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -49,8 +49,6 @@ """ -from __future__ import annotations - import logging import geopandas as gpd @@ -76,6 +74,14 @@ "gabriel_edge", "geometry", ] +BUS_SUFFIX = { + "carbon_dioxide": "co2 stored", + "hydrogen": "H2", +} +LINK_PREFIX = { + "carbon_dioxide": "CO2 pipeline", + "hydrogen": "H2 pipeline", +} def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: @@ -473,7 +479,7 @@ def mark_selected_edges( snakemake = mock_snakemake( "build_transmission_topology", - carrier="carbon_dioxide", + carrier="hydrogen", clusters="200", run="nodes200", configfiles=["config/config.200.yaml"], @@ -483,6 +489,7 @@ def mark_selected_edges( set_scenario_config(snakemake) carrier_enabled = bool(snakemake.params["carrier_enabled"]) + carrier = snakemake.wildcards.carrier gabriel_filter_enabled = bool(snakemake.params["gabriel_filter_enabled"]) min_degree = int(snakemake.params["min_degree"]) length_factor = float(snakemake.params["length_factor"]) @@ -511,6 +518,16 @@ def mark_selected_edges( selected_edges = selected_edges[COLS_EDGES] delaunay_graph = mark_selected_edges(delaunay_graph, selected_edges) + # Rename buses and link names + delaunay_graph[["bus0", "bus1"]] = ( + delaunay_graph[["bus0", "bus1"]] + " " + BUS_SUFFIX[carrier] + ) + delaunay_graph["name"] = LINK_PREFIX[carrier] + " " + delaunay_graph["name"] + selected_edges[["bus0", "bus1"]] = ( + selected_edges[["bus0", "bus1"]] + " " + BUS_SUFFIX[carrier] + ) + selected_edges["name"] = LINK_PREFIX[carrier] + " " + selected_edges["name"] + # Export delaunay_graph.to_file(snakemake.output.all_edges) selected_edges.to_file(snakemake.output.candidates) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 5801f7fcbe..1db5de455c 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -829,7 +829,6 @@ class SectorConfig(BaseModel): description="Transmission efficiency configuration.", ) - H2_network: bool = Field(True, description="Add option for new hydrogen pipelines.") gas_network: bool = Field( True, description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d9434ef76e..682f4bef96 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -11,6 +11,7 @@ from itertools import product from types import SimpleNamespace +import geopandas as gpd import networkx as nx import numpy as np import pandas as pd @@ -1752,6 +1753,8 @@ def add_h2_gas_infrastructure( costs, pop_layout, h2_cavern_file, + h2_transmission_candidates, + h2_transmission_enable, cavern_types, clustered_gas_network_file, gas_input_nodes, @@ -1771,6 +1774,10 @@ def add_h2_gas_infrastructure( Population layout with index of locations/nodes h2_cavern_file : str Path to CSV file containing hydrogen cavern storage potentials + h2_transmission_candidates : str + Path to GeoJSON file containing hydrogen pipeline candidates + h2_transmission_enable : bool + Whether hydrogen transmission topology is enabled in config cavern_types : list List of underground storage types to consider clustered_gas_network_file : str, optional @@ -1787,7 +1794,6 @@ def add_h2_gas_infrastructure( - hydrogen_underground_storage : bool - gas_network : bool - H2_retrofit : bool - - H2_network : bool - methanation : bool - coal_cc : bool - SMR_cc : bool @@ -2069,21 +2075,20 @@ def add_h2_gas_infrastructure( lifetime=costs.at["H2 (g) pipeline repurposed", "lifetime"], ) - if options["H2_network"]: + if h2_transmission_enable: logger.info("Add options for new hydrogen pipelines.") - h2_pipes = create_network_topology( - n, "H2 pipeline ", carriers=["DC", "gas pipeline"] - ) - h2_buses_loc = n.buses.query("carrier == 'H2'").location # noqa: F841 - h2_pipes = h2_pipes.query("bus0 in @h2_buses_loc and bus1 in @h2_buses_loc") + h2_pipes = gpd.read_file(h2_transmission_candidates).set_index("name") + h2_bus_ids = n.buses.index[n.buses.carrier == "H2"] + h2_pipes = h2_pipes[ + h2_pipes["bus0"].isin(h2_bus_ids) & h2_pipes["bus1"].isin(h2_bus_ids) + ] - # TODO Add efficiency losses n.add( "Link", h2_pipes.index, - bus0=h2_pipes.bus0.values + " H2", - bus1=h2_pipes.bus1.values + " H2", + bus0=h2_pipes.bus0.values, + bus1=h2_pipes.bus1.values, p_min_pu=-1, p_nom_extendable=True, length=h2_pipes.length.values, @@ -6241,9 +6246,10 @@ def add_import_options( snakemake = mock_snakemake( "prepare_sector_network", opts="", - clusters="10", + clusters="200", sector_opts="", planning_horizons="2050", + configfiles=["config/config.200.yaml"], ) configure_logging(snakemake) # pylint: disable=E0606 @@ -6256,6 +6262,9 @@ def add_import_options( investment_year = int(snakemake.wildcards.planning_horizons) + # Transmission + h2_transmission_enable = snakemake.params.h2_transmission_enable + n = pypsa.Network(snakemake.input.network) pop_layout = pd.read_csv(snakemake.input.clustered_pop_layout, index_col=0) @@ -6340,6 +6349,8 @@ def add_import_options( costs=costs, pop_layout=pop_layout, h2_cavern_file=snakemake.input.h2_cavern, + h2_transmission_candidates=snakemake.input.h2_transmission_candidates, + h2_transmission_enable=h2_transmission_enable, cavern_types=snakemake.params.sector["hydrogen_underground_storage_locations"], clustered_gas_network_file=snakemake.input.clustered_gas_network, gas_input_nodes=gas_input_nodes, @@ -6494,7 +6505,7 @@ def add_import_options( if not options["electricity_transmission_grid"]: decentral(n) - if not options["H2_network"]: + if not snakemake.config["transmission"]["hydrogen"]["enable"]: remove_h2_network(n) if options["co2_network"]: @@ -6601,4 +6612,8 @@ def add_import_options( sanitize_carriers(n, snakemake.config) sanitize_locations(n) + # # Filter for H2 links and map to width 1, others zero + # lwidth = (n.links.carrier=="H2 pipeline").astype(int)*5 + # n.explore(branch_components=["Link"], link_width=lwidth) + n.export_to_netcdf(snakemake.output[0]) From e3bd7f5afb9fc56cfe5a6513678637c858c15d6a Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 20 Apr 2026 16:24:36 +0200 Subject: [PATCH 06/32] Implemented new key for CO2 networks. --- config/config.default.yaml | 4 +- config/examples/config.iterative.yaml | 5 +- config/schema.default.json | 50 +++++++----- config/test/config.myopic.yaml | 5 +- config/test/config.overnight.yaml | 5 +- config/test/config.perfect.yaml | 5 +- rules/build_sector.smk | 8 +- scripts/build_transmission_topology.py | 34 +++++++- scripts/lib/validation/config/sector.py | 8 -- scripts/lib/validation/config/transmission.py | 5 ++ scripts/prepare_sector_network.py | 81 +++++++++++-------- 11 files changed, 139 insertions(+), 71 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 9ec9a9c7f0..b46391673c 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -192,12 +192,14 @@ transmission: 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 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite atlite: @@ -885,8 +887,6 @@ sector: co2_sequestration_cost: 30 co2_sequestration_lifetime: 50 co2_spatial: true - co2_network: true - co2_network_cost_factor: 1 cc_fraction: 0.9 hydrogen_underground_storage: true hydrogen_underground_storage_locations: diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index 18076927e2..cd01a642f0 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -37,6 +37,10 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] +transmission: + carbon_dioxide: + enable: false + atlite: default_cutout: be-03-2013-era5 cutouts: @@ -85,7 +89,6 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false - co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/config/schema.default.json b/config/schema.default.json index 4c34bdbc7e..f3e0657b34 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4698,16 +4698,6 @@ "description": "Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", "type": "boolean" }, - "co2_network": { - "default": true, - "description": "Add option for planning a new carbon dioxide transmission network.", - "type": "boolean" - }, - "co2_network_cost_factor": { - "default": 1, - "description": "The cost factor for the capital cost of the carbon dioxide transmission network.", - "type": "number" - }, "cc_fraction": { "default": 0.9, "description": "The default fraction of CO2 captured with post-combustion capture.", @@ -5630,6 +5620,12 @@ "description": "Multiplier applied to geometric corridor lengths.", "exclusiveMinimum": 0, "type": "number" + }, + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" } } }, @@ -5662,6 +5658,12 @@ "description": "Multiplier applied to geometric corridor lengths.", "exclusiveMinimum": 0, "type": "number" + }, + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" } } } @@ -8175,6 +8177,12 @@ "description": "Multiplier applied to geometric corridor lengths.", "exclusiveMinimum": 0, "type": "number" + }, + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" } } }, @@ -8912,6 +8920,12 @@ "description": "Multiplier applied to geometric corridor lengths.", "exclusiveMinimum": 0, "type": "number" + }, + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" } } }, @@ -8944,6 +8958,12 @@ "description": "Multiplier applied to geometric corridor lengths.", "exclusiveMinimum": 0, "type": "number" + }, + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" } } } @@ -11323,16 +11343,6 @@ "description": "Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", "type": "boolean" }, - "co2_network": { - "default": true, - "description": "Add option for planning a new carbon dioxide transmission network.", - "type": "boolean" - }, - "co2_network_cost_factor": { - "default": 1, - "description": "The cost factor for the capital cost of the carbon dioxide transmission network.", - "type": "number" - }, "cc_fraction": { "default": 0.9, "description": "The default fraction of CO2 captured with post-combustion capture.", diff --git a/config/test/config.myopic.yaml b/config/test/config.myopic.yaml index e76df5a395..0dc89b2718 100644 --- a/config/test/config.myopic.yaml +++ b/config/test/config.myopic.yaml @@ -56,7 +56,6 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false - co2_network: false methanol: methanol_to_power: ocgt: false @@ -78,6 +77,10 @@ electricity: estimate_renewable_capacities: enable: false +transmission: + carbon_dioxide: + enable: false + atlite: default_cutout: be-03-2013-era5 cutouts: diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index f2ba5209e5..69c7d431cb 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -36,6 +36,10 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] +transmission: + carbon_dioxide: + enable: false + atlite: default_cutout: be-03-2013-era5 cutouts: @@ -96,7 +100,6 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false - co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/config/test/config.perfect.yaml b/config/test/config.perfect.yaml index 16a5199ad3..1659766f30 100644 --- a/config/test/config.perfect.yaml +++ b/config/test/config.perfect.yaml @@ -39,6 +39,10 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] +transmission: + carbon_dioxide: + enable: false + sector: min_part_load_fischer_tropsch: 0 min_part_load_methanolisation: 0 @@ -68,7 +72,6 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false - co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/rules/build_sector.smk b/rules/build_sector.smk index c5051bc014..4cae0f8c00 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -149,6 +149,7 @@ rule cluster_gas_network: rule build_transmission_topology: input: network=resources("networks/base_s_{clusters}.nc"), + offshore_shapes=resources("offshore_shapes.geojson"), output: all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), @@ -1609,6 +1610,11 @@ rule prepare_sector_network: unpack(input_heat_source_power), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, + co2_transmission_candidates=lambda w: ( + resources("transmission/carbon_dioxide_candidates_{clusters}.geojson") + if config_provider("transmission", "carbon_dioxide", "enable")(w) + else [] + ), h2_transmission_candidates=lambda w: ( resources("transmission/hydrogen_candidates_{clusters}.geojson") if config_provider("transmission", "hydrogen", "enable")(w) @@ -1783,7 +1789,7 @@ rule prepare_sector_network: temperature_limited_stores=config_provider( "sector", "district_heating", "temperature_limited_stores" ), - h2_transmission_enable=config_provider("transmission", "hydrogen", "enable"), + transmission=config_provider("transmission"), message: "Preparing integrated sector-coupled energy network for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizon, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index a0e1761828..53e9fd53d7 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -73,6 +73,7 @@ "length", "gabriel_edge", "geometry", + "underwater_fraction", ] BUS_SUFFIX = { "carbon_dioxide": "co2 stored", @@ -471,6 +472,33 @@ def mark_selected_edges( return delaunay_graph +def add_underwater_fraction( + edges: gpd.GeoDataFrame, + offshore_shapes: gpd.GeoDataFrame, +) -> gpd.GeoDataFrame: + """ + Compute the fraction of each edge's length that lies within offshore regions. + + Parameters + ---------- + edges : gpd.GeoDataFrame + Edge table with LineString geometry in ``EPSG:4326``. + offshore_shapes : gpd.GeoDataFrame + Offshore region polygons used to determine submarine sections. + + Returns + ------- + gpd.GeoDataFrame + ``edges`` with an added ``underwater_fraction`` column in ``[0, 1]``. + """ + offshore_union = offshore_shapes.to_crs(DISTANCE_CRS).union_all() + geoms = edges.geometry.to_crs(DISTANCE_CRS) + edges["underwater_fraction"] = ( + (geoms.intersection(offshore_union).length / geoms.length).fillna(0.0).round(2) + ) + return edges + + if __name__ == "__main__": is_mock_run = "snakemake" not in globals() @@ -479,7 +507,7 @@ def mark_selected_edges( snakemake = mock_snakemake( "build_transmission_topology", - carrier="hydrogen", + carrier="carbon_dioxide", clusters="200", run="nodes200", configfiles=["config/config.200.yaml"], @@ -500,6 +528,7 @@ def mark_selected_edges( ) n = pypsa.Network(snakemake.input.network) + offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) # Delaunay triangulation and Gabriel filtering bus_ids, coords_geo, coords_meter = get_bus_coordinates(n) @@ -509,6 +538,9 @@ def mark_selected_edges( coords_meter=coords_meter, length_factor=length_factor, ) + + add_underwater_fraction(delaunay_graph, offshore_shapes) + selected_edges = prepare_candidate_edges( delaunay_graph=delaunay_graph, gabriel_filter_enabled=gabriel_filter_enabled, diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 1db5de455c..56b701f1d4 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -740,14 +740,6 @@ class SectorConfig(BaseModel): True, description="Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", ) - co2_network: bool = Field( - True, - description="Add option for planning a new carbon dioxide transmission network.", - ) - co2_network_cost_factor: float = Field( - 1, - description="The cost factor for the capital cost of the carbon dioxide transmission network.", - ) cc_fraction: float = Field( 0.9, description="The default fraction of CO2 captured with post-combustion capture.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index 4e74b901d5..f70aa5d9d7 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -41,6 +41,11 @@ class _TransmissionCarrierConfig(BaseModel): gt=0, description="Multiplier applied to geometric corridor lengths.", ) + cost_factor: float = Field( + 1, + gt=0, + description="Multiplier applied to the capital cost of transmission infrastructure.", + ) class TransmissionConfig(BaseModel): diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 682f4bef96..1c2c67064a 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -128,7 +128,9 @@ def define_spatial(nodes, options): spatial.gas.biogas_to_gas_cc = nodes + " biogas to gas CC" else: spatial.gas.biogas_to_gas_cc = ["EU biogas to gas CC"] - if options.get("co2_spatial", options["co2_network"]): + if options.get( + "co2_spatial", snakemake.config["transmission"]["carbon_dioxide"]["enable"] + ): spatial.gas.industry_cc = nodes + " gas for industry CC" else: spatial.gas.industry_cc = ["gas for industry CC"] @@ -872,7 +874,9 @@ def add_co2_tracking( ) -def add_co2_network(n, costs, co2_network_cost_factor=1.0): +def add_co2_network( + n, co2_transmission_candidates, costs, co2_transmission_cost_factor=1.0 +): """ Add CO2 transport network to the PyPSA network. @@ -884,11 +888,13 @@ def add_co2_network(n, costs, co2_network_cost_factor=1.0): ---------- n : pypsa.Network The PyPSA network container object + co2_transmission_candidates : str + Path to GeoJSON file containing CO2 pipeline candidates costs : pd.DataFrame Cost assumptions for different technologies. Must contain entries for 'CO2 pipeline' and 'CO2 submarine pipeline' with 'capital_cost' and 'lifetime' columns - co2_network_cost_factor : float, optional + co2_transmission_cost_factor : float, optional Factor to scale the capital costs of the CO2 network, default 1.0 Returns @@ -903,7 +909,7 @@ def add_co2_network(n, costs, co2_network_cost_factor=1.0): created using the create_network_topology helper function. """ logger.info("Adding CO2 network.") - co2_links = create_network_topology(n, "CO2 pipeline ") + co2_links = gpd.read_file(co2_transmission_candidates).set_index("name") if "underwater_fraction" not in co2_links.columns: co2_links["underwater_fraction"] = 0.0 @@ -911,24 +917,24 @@ def add_co2_network(n, costs, co2_network_cost_factor=1.0): cost_onshore = ( (1 - co2_links.underwater_fraction) * costs.at["CO2 pipeline", "capital_cost"] - * co2_links.length + * co2_links["length"] ) cost_submarine = ( co2_links.underwater_fraction * costs.at["CO2 submarine pipeline", "capital_cost"] - * co2_links.length + * co2_links["length"] ) capital_cost = cost_onshore + cost_submarine - capital_cost *= co2_network_cost_factor + capital_cost *= co2_transmission_cost_factor n.add( "Link", co2_links.index, - bus0=co2_links.bus0.values + " co2 stored", - bus1=co2_links.bus1.values + " co2 stored", + bus0=co2_links["bus0"].values, + bus1=co2_links["bus1"].values, p_min_pu=-1, p_nom_extendable=True, - length=co2_links.length.values, + length=co2_links["length"].values, capital_cost=capital_cost.values, carrier="CO2 pipeline", lifetime=costs.at["CO2 pipeline", "lifetime"], @@ -1760,6 +1766,7 @@ def add_h2_gas_infrastructure( gas_input_nodes, spatial, options, + h2_transmission_cost_factor=1.0, ): """ Add hydrogen and gas infrastructure to the network. @@ -1800,8 +1807,8 @@ def add_h2_gas_infrastructure( - SMR : bool - min_part_load_methanation : float - cc_fraction : float - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. + h2_transmission_cost_factor : float, optional + Factor to scale the capital costs of the H2 network, default 1.0 Returns ------- @@ -2062,19 +2069,20 @@ def add_h2_gas_infrastructure( n.add( "Link", h2_pipes.index, - bus0=h2_pipes.bus0 + " H2", - bus1=h2_pipes.bus1 + " H2", + bus0=h2_pipes["bus0"] + " H2", + bus1=h2_pipes["bus1"] + " H2", p_min_pu=-1.0, # allow that all H2 retrofit pipelines can be used in both directions - p_nom_max=h2_pipes.p_nom * options["H2_retrofit_capacity_per_CH4"], + p_nom_max=h2_pipes["p_nom"] * options["H2_retrofit_capacity_per_CH4"], p_nom_extendable=True, - length=h2_pipes.length, + length=h2_pipes["length"], capital_cost=costs.at["H2 (g) pipeline repurposed", "capital_cost"] - * h2_pipes.length, - tags=h2_pipes.name, + * h2_pipes["length"], + tags=h2_pipes["name"], carrier="H2 pipeline retrofitted", lifetime=costs.at["H2 (g) pipeline repurposed", "lifetime"], ) + # TODO: implement offshore costs, using 1.5-2.0x multiplier if h2_transmission_enable: logger.info("Add options for new hydrogen pipelines.") @@ -2087,13 +2095,14 @@ def add_h2_gas_infrastructure( n.add( "Link", h2_pipes.index, - bus0=h2_pipes.bus0.values, - bus1=h2_pipes.bus1.values, + bus0=h2_pipes["bus0"].values, + bus1=h2_pipes["bus1"].values, p_min_pu=-1, p_nom_extendable=True, - length=h2_pipes.length.values, + length=h2_pipes["length"].values, capital_cost=costs.at["H2 (g) pipeline", "capital_cost"] - * h2_pipes.length.values, + * h2_pipes["length"].values + * h2_transmission_cost_factor, carrier="H2 pipeline", lifetime=costs.at["H2 (g) pipeline", "lifetime"], ) @@ -4483,6 +4492,7 @@ def add_industry( spatial: SimpleNamespace, cf_industry: dict, investment_year: int, + co2_transmission_enable: bool, ): """ Add industry and their corresponding carrier buses to the network. @@ -5022,7 +5032,7 @@ def add_industry( unit="t_co2", ) - if options["co2_spatial"] or options["co2_network"]: + if options["co2_spatial"] or co2_transmission_enable: p_set = ( -industrial_demand.loc[nodes, "process emission"].rename( index=lambda x: x + " process emissions" @@ -6258,13 +6268,11 @@ def add_import_options( options = snakemake.params.sector cf_industry = snakemake.params.industry + cf_transmission = snakemake.params.transmission ext_carriers = snakemake.params.electricity.get("extendable_carriers", dict()) investment_year = int(snakemake.wildcards.planning_horizons) - # Transmission - h2_transmission_enable = snakemake.params.h2_transmission_enable - n = pypsa.Network(snakemake.input.network) pop_layout = pd.read_csv(snakemake.input.clustered_pop_layout, index_col=0) @@ -6350,12 +6358,13 @@ def add_import_options( pop_layout=pop_layout, h2_cavern_file=snakemake.input.h2_cavern, h2_transmission_candidates=snakemake.input.h2_transmission_candidates, - h2_transmission_enable=h2_transmission_enable, + h2_transmission_enable=cf_transmission["hydrogen"]["enable"], cavern_types=snakemake.params.sector["hydrogen_underground_storage_locations"], clustered_gas_network_file=snakemake.input.clustered_gas_network, gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, + h2_transmission_cost_factor=cf_transmission["hydrogen"]["cost_factor"], ) # Hydrogen already implemented in add_h2_gas_infrastructure @@ -6461,6 +6470,7 @@ def add_import_options( spatial=spatial, cf_industry=cf_industry, investment_year=investment_year, + co2_transmission_enable=cf_transmission["carbon_dioxide"]["enable"], ) if options["shipping"]: @@ -6508,12 +6518,13 @@ def add_import_options( if not snakemake.config["transmission"]["hydrogen"]["enable"]: remove_h2_network(n) - if options["co2_network"]: + if cf_transmission["carbon_dioxide"]["enable"]: add_co2_network( - n, - costs, - co2_network_cost_factor=snakemake.config["sector"][ - "co2_network_cost_factor" + n=n, + co2_transmission_candidates=snakemake.input.co2_transmission_candidates, + costs=costs, + co2_transmission_cost_factor=cf_transmission["carbon_dioxide"][ + "cost_factor" ], ) @@ -6612,8 +6623,8 @@ def add_import_options( sanitize_carriers(n, snakemake.config) sanitize_locations(n) - # # Filter for H2 links and map to width 1, others zero - # lwidth = (n.links.carrier=="H2 pipeline").astype(int)*5 - # n.explore(branch_components=["Link"], link_width=lwidth) + # Filter carrier links and map to width 1, others zero + lwidth = (n.links["carrier"] == "CO2 pipeline").astype(int) * 5 + n.explore(branch_components=["Link"], link_width=lwidth) n.export_to_netcdf(snakemake.output[0]) From 67c2c58addba07ad828a3b8c87eb5b8bbaa8ffc6 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 20 Apr 2026 16:29:17 +0200 Subject: [PATCH 07/32] Bug fix snakemake. --- scripts/prepare_sector_network.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 1c2c67064a..d682f474f7 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -51,7 +51,7 @@ logger = logging.getLogger(__name__) -def define_spatial(nodes, options): +def define_spatial(nodes, options, cf_transmission) """ Namespace for spatial. @@ -129,7 +129,7 @@ def define_spatial(nodes, options): else: spatial.gas.biogas_to_gas_cc = ["EU biogas to gas CC"] if options.get( - "co2_spatial", snakemake.config["transmission"]["carbon_dioxide"]["enable"] + "co2_spatial", cf_transmission["carbon_dioxide"]["enable"] ): spatial.gas.industry_cc = nodes + " gas for industry CC" else: @@ -6310,7 +6310,7 @@ def add_import_options( year = int(snakemake.params["energy_totals_year"]) heating_efficiencies = pd.read_csv(fn, index_col=[1, 0]).loc[year] - spatial = define_spatial(pop_layout.index, options) + spatial = define_spatial(pop_layout.index, options, cf_transmission) if snakemake.params.foresight in ["myopic", "perfect"]: add_lifetime_wind_solar(n, costs) From e2d94451ab2955d53cc33f51245ee62ca37bd628 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 20 Apr 2026 16:31:30 +0200 Subject: [PATCH 08/32] Missing trailing : --- scripts/prepare_sector_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d682f474f7..d133e4aa2f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -51,7 +51,7 @@ logger = logging.getLogger(__name__) -def define_spatial(nodes, options, cf_transmission) +def define_spatial(nodes, options, cf_transmission): """ Namespace for spatial. From 590e5b17732b2cd478f820a88671931f06fa7f45 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 10:09:18 +0200 Subject: [PATCH 09/32] fix: added cf_transmission to add_existing_baseyear. --- rules/solve_myopic.smk | 1 + rules/solve_perfect.smk | 1 + scripts/add_existing_baseyear.py | 7 ++++--- scripts/prepare_sector_network.py | 4 +--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index edc8c59a94..65eedfb104 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -45,6 +45,7 @@ rule add_existing_baseyear: costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), + transmission=config_provider("transmission"), message: "Adding existing infrastructure for base year for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizons, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index be21e6ffcc..8f07ff59d8 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -45,6 +45,7 @@ rule add_existing_baseyear: costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), + transmission=config_provider("transmission"), message: "Adding existing infrastructure for base year for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizons, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index 7e03eccb9d..d42e37e635 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -779,15 +779,16 @@ def add_heating_capacities_installed_before_baseyear( update_config_from_wildcards(snakemake.config, snakemake.wildcards) options = snakemake.params.sector - renewable_carriers = snakemake.params.carriers - baseyear = snakemake.params.baseyear + cf_transmission = snakemake.params.transmission n = pypsa.Network(snakemake.input.network) # define spatial resolution of carriers - spatial = define_spatial(n.buses[n.buses.carrier == "AC"].index, options) + spatial = define_spatial( + n.buses[n.buses.carrier == "AC"].index, options, cf_transmission + ) add_build_year_to_new_assets(n, baseyear) costs = load_costs(snakemake.input.costs) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d133e4aa2f..8701c76538 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -128,9 +128,7 @@ def define_spatial(nodes, options, cf_transmission): spatial.gas.biogas_to_gas_cc = nodes + " biogas to gas CC" else: spatial.gas.biogas_to_gas_cc = ["EU biogas to gas CC"] - if options.get( - "co2_spatial", cf_transmission["carbon_dioxide"]["enable"] - ): + if options.get("co2_spatial", cf_transmission["carbon_dioxide"]["enable"]): spatial.gas.industry_cc = nodes + " gas for industry CC" else: spatial.gas.industry_cc = ["gas for industry CC"] From 2d8bf6f8840b9960199755bd74eaa7b120425267 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 10:46:27 +0200 Subject: [PATCH 10/32] Moved electricity transmission and transmission limit to transmission. --- config/config.default.yaml | 5 +- config/schema.default.json | 67 +++++++++++++------ config/test/config.tyndp.yaml | 5 +- rules/build_electricity.smk | 4 +- rules/postprocess.smk | 4 +- scripts/_helpers.py | 4 +- scripts/lib/validation/config/electricity.py | 4 -- scripts/lib/validation/config/sector.py | 4 -- scripts/lib/validation/config/transmission.py | 27 ++++++-- scripts/prepare_sector_network.py | 2 +- 10 files changed, 84 insertions(+), 42 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index b46391673c..199576e35a 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -182,10 +182,12 @@ electricity: autarky: enable: false by_country: false - transmission_limit: vopt # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission transmission: + electricity: + enable: true + transmission_limit: vopt hydrogen: enable: true gabriel_filter: @@ -915,7 +917,6 @@ sector: use_methanation_waste_heat: 0.25 use_fuel_cell_waste_heat: 1 use_electrolysis_waste_heat: 0.25 - electricity_transmission_grid: true electricity_distribution_grid: true electricity_distribution_grid_cost_factor: 1.0 electricity_grid_connection: true diff --git a/config/schema.default.json b/config/schema.default.json index f3e0657b34..8a6292d8c6 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -2337,11 +2337,6 @@ "type": "boolean" } } - }, - "transmission_limit": { - "default": "vopt", - "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - "type": "string" } } }, @@ -4819,11 +4814,6 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_transmission_grid": { - "default": true, - "description": "Switch for enabling/disabling the electricity transmission grid.", - "type": "boolean" - }, "electricity_distribution_grid": { "default": true, "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", @@ -5591,6 +5581,21 @@ "TransmissionConfig": { "description": "Configuration for `transmission` settings.", "properties": { + "electricity": { + "description": "Configuration for electricity transmission grid.", + "properties": { + "enable": { + "default": true, + "description": "Switch for enabling/disabling the electricity transmission grid.", + "type": "boolean" + }, + "transmission_limit": { + "default": "vopt", + "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + "type": "string" + } + } + }, "hydrogen": { "description": "Configuration for a single transmission carrier.", "properties": { @@ -8148,7 +8153,22 @@ } } }, - "_TransmissionCarrierConfig": { + "_TransmissionCarrierConfigElectricity": { + "description": "Configuration for electricity transmission grid.", + "properties": { + "enable": { + "default": true, + "description": "Switch for enabling/disabling the electricity transmission grid.", + "type": "boolean" + }, + "transmission_limit": { + "default": "vopt", + "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + "type": "string" + } + } + }, + "_TransmissionCarrierConfigGeneral": { "description": "Configuration for a single transmission carrier.", "properties": { "enable": { @@ -8880,17 +8900,27 @@ "type": "boolean" } } - }, - "transmission_limit": { - "default": "vopt", - "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - "type": "string" } } }, "transmission": { "description": "Configuration for `transmission` settings.", "properties": { + "electricity": { + "description": "Configuration for electricity transmission grid.", + "properties": { + "enable": { + "default": true, + "description": "Switch for enabling/disabling the electricity transmission grid.", + "type": "boolean" + }, + "transmission_limit": { + "default": "vopt", + "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + "type": "string" + } + } + }, "hydrogen": { "description": "Configuration for a single transmission carrier.", "properties": { @@ -11464,11 +11494,6 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_transmission_grid": { - "default": true, - "description": "Switch for enabling/disabling the electricity transmission grid.", - "type": "boolean" - }, "electricity_distribution_grid": { "default": true, "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", diff --git a/config/test/config.tyndp.yaml b/config/test/config.tyndp.yaml index 7d3cc73073..e920538f37 100644 --- a/config/test/config.tyndp.yaml +++ b/config/test/config.tyndp.yaml @@ -23,7 +23,6 @@ snapshots: electricity: base_network: tyndp - transmission_limit: v1.0 extendable_carriers: Generator: [OCGT] @@ -33,6 +32,10 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] +transmission: + electricity: + transmission_limit: v1.0 + atlite: default_cutout: europe-2013-03-sarah3-era5 cutouts: diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index bb6c4d270b..f3738db2ad 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -871,7 +871,9 @@ rule prepare_network: adjustments=config_provider("adjustments", "electricity"), autarky=config_provider("electricity", "autarky", default={}), drop_leap_day=config_provider("enable", "drop_leap_day"), - transmission_limit=config_provider("electricity", "transmission_limit"), + transmission_limit=config_provider( + "transmission", "electricity", "transmission_limit" + ), message: "Preparing network for model with {wildcards.clusters} clusters and options {wildcards.opts}" script: diff --git a/rules/postprocess.smk b/rules/postprocess.smk index eeb9e5a26c..b2c92b0814 100644 --- a/rules/postprocess.smk +++ b/rules/postprocess.smk @@ -62,7 +62,9 @@ if config["foresight"] != "perfect": mem_mb=10000, params: plotting=config_provider("plotting"), - transmission_limit=config_provider("electricity", "transmission_limit"), + transmission_limit=config_provider( + "transmission", "electricity", "transmission_limit" + ), message: "Plotting power network for {wildcards.clusters} clusters, {wildcards.opts} electric options, {wildcards.sector_opts} sector options and {wildcards.planning_horizons} planning horizons" script: diff --git a/scripts/_helpers.py b/scripts/_helpers.py index d316d3ed22..aea01fe491 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -750,7 +750,7 @@ def update_config_from_wildcards(config, w, inplace=True): for o in opts: if o.startswith("lv") or o.startswith("lc"): - config["electricity"]["transmission_limit"] = o[1:] + config["transmission"]["electricity"]["transmission_limit"] = o[1:] break if w.get("sector_opts"): @@ -792,7 +792,7 @@ def update_config_from_wildcards(config, w, inplace=True): config["clustering"]["temporal"]["resolution_sector"] = nhours if "decentral" in opts: - config["sector"]["electricity_transmission_grid"] = False + config["transmission"]["electricity"]["enable"] = False if "noH2network" in opts: config["transmission"]["hydrogen"] = False diff --git a/scripts/lib/validation/config/electricity.py b/scripts/lib/validation/config/electricity.py index d343cb06cd..cdcecf0760 100644 --- a/scripts/lib/validation/config/electricity.py +++ b/scripts/lib/validation/config/electricity.py @@ -257,9 +257,5 @@ class ElectricityConfig(BaseModel): default_factory=_AutarkyConfig, description="Autarky configuration.", ) - transmission_limit: str = Field( - "vopt", - description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - ) model_config = ConfigDict(populate_by_name=True) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 56b701f1d4..1cf1f4c976 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -799,10 +799,6 @@ class SectorConfig(BaseModel): description="Add option for using waste heat of electrolysis in district heating networks.", ) - electricity_transmission_grid: bool = Field( - True, - description="Switch for enabling/disabling the electricity transmission grid.", - ) electricity_distribution_grid: bool = Field( True, description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index f70aa5d9d7..7550fc0724 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -25,7 +25,7 @@ class _GabrielFilterConfig(BaseModel): ) -class _TransmissionCarrierConfig(BaseModel): +class _TransmissionCarrierConfigGeneral(BaseModel): """Configuration for a single transmission carrier.""" enable: bool = Field( @@ -48,14 +48,31 @@ class _TransmissionCarrierConfig(BaseModel): ) +class _TransmissionCarrierConfigElectricity(BaseModel): + """Configuration for electricity transmission grid.""" + + enable: bool = Field( + True, + description="Switch for enabling/disabling the electricity transmission grid.", + ) + transmission_limit: str = Field( + "vopt", + description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + ) + + class TransmissionConfig(BaseModel): """Configuration for `transmission` settings.""" - hydrogen: _TransmissionCarrierConfig = Field( - default_factory=_TransmissionCarrierConfig, + electricity: _TransmissionCarrierConfigElectricity = Field( + default_factory=_TransmissionCarrierConfigElectricity, + description="Configuration for electricity transmission grid.", + ) + hydrogen: _TransmissionCarrierConfigGeneral = Field( + default_factory=_TransmissionCarrierConfigGeneral, description="Configuration for hydrogen transmission candidates.", ) - carbon_dioxide: _TransmissionCarrierConfig = Field( - default_factory=_TransmissionCarrierConfig, + carbon_dioxide: _TransmissionCarrierConfigGeneral = Field( + default_factory=_TransmissionCarrierConfigGeneral, description="Configuration for carbon dioxide transmission candidates.", ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 8701c76538..70cea9f411 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -6510,7 +6510,7 @@ def add_import_options( if options["dac"]: add_dac(n, costs) - if not options["electricity_transmission_grid"]: + if not cf_transmission["electricity"]["enable"]: decentral(n) if not snakemake.config["transmission"]["hydrogen"]["enable"]: From 164b56fb53f6e35a66e5ffdae95f4c6dd8f06682 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 10:58:46 +0200 Subject: [PATCH 11/32] Moved base_network config to transmission. --- config/config.default.yaml | 2 +- ...config.distribution-grid-experimental.yaml | 6 ++- config/examples/config.osm-release.yaml | 6 ++- config/schema.default.json | 50 +++++++++++-------- rules/build_electricity.smk | 6 +-- rules/development.smk | 2 +- scripts/base_network.py | 4 +- scripts/lib/validation/config/electricity.py | 4 -- scripts/lib/validation/config/transmission.py | 6 +++ 9 files changed, 53 insertions(+), 33 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 199576e35a..6599913610 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -112,7 +112,6 @@ electricity: - 400.0 - 500.0 - 750.0 - base_network: osm gaslimit_enable: false gaslimit: false co2limit_enable: false @@ -187,6 +186,7 @@ electricity: transmission: electricity: enable: true + base_network: osm transmission_limit: vopt hydrogen: enable: true diff --git a/config/examples/config.distribution-grid-experimental.yaml b/config/examples/config.distribution-grid-experimental.yaml index 06642fe5ae..a0d978d211 100644 --- a/config/examples/config.distribution-grid-experimental.yaml +++ b/config/examples/config.distribution-grid-experimental.yaml @@ -45,7 +45,11 @@ countries: electricity: voltages: [63., 66., 90., 110., 132., 150., 220., 300., 330., 380., 400., 500., 750.] - base_network: osm + +transmission: + electricity: + enable: true + base_network: osm transmission_projects: enable: false diff --git a/config/examples/config.osm-release.yaml b/config/examples/config.osm-release.yaml index 47461fde41..14644d66c6 100644 --- a/config/examples/config.osm-release.yaml +++ b/config/examples/config.osm-release.yaml @@ -52,7 +52,11 @@ electricity: - 400.0 - 500.0 - 750.0 - base_network: osm + +transmission: + electricity: + enable: true + base_network: osm transmission_projects: enable: false diff --git a/config/schema.default.json b/config/schema.default.json index 8a6292d8c6..e449fb7152 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -2063,16 +2063,6 @@ }, "type": "array" }, - "base_network": { - "default": "osm", - "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - "enum": [ - "entsoegridkit", - "osm", - "tyndp" - ], - "type": "string" - }, "gaslimit_enable": { "default": false, "description": "Add an overall absolute gas limit configured in `electricity: gaslimit`.", @@ -5589,6 +5579,16 @@ "description": "Switch for enabling/disabling the electricity transmission grid.", "type": "boolean" }, + "base_network": { + "default": "osm", + "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + "enum": [ + "entsoegridkit", + "osm", + "tyndp" + ], + "type": "string" + }, "transmission_limit": { "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", @@ -8161,6 +8161,16 @@ "description": "Switch for enabling/disabling the electricity transmission grid.", "type": "boolean" }, + "base_network": { + "default": "osm", + "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + "enum": [ + "entsoegridkit", + "osm", + "tyndp" + ], + "type": "string" + }, "transmission_limit": { "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", @@ -8626,16 +8636,6 @@ }, "type": "array" }, - "base_network": { - "default": "osm", - "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - "enum": [ - "entsoegridkit", - "osm", - "tyndp" - ], - "type": "string" - }, "gaslimit_enable": { "default": false, "description": "Add an overall absolute gas limit configured in `electricity: gaslimit`.", @@ -8914,6 +8914,16 @@ "description": "Switch for enabling/disabling the electricity transmission grid.", "type": "boolean" }, + "base_network": { + "default": "osm", + "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + "enum": [ + "entsoegridkit", + "osm", + "tyndp" + ], + "type": "string" + }, "transmission_limit": { "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index f3738db2ad..8fa29b48de 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -60,7 +60,7 @@ rule build_powerplants: def input_base_network(w): - base_network = config_provider("electricity", "base_network")(w) + base_network = config_provider("transmission", "electricity", "base_network")(w) source = config_provider("data", "osm", "source")(w) components = {"buses", "lines", "links", "converters", "transformers"} if (base_network == "osm") and (source == "archive"): @@ -694,11 +694,11 @@ def input_custom_busmap(w): mode = config_provider("clustering", "mode", default="busmap")(w) if mode == "custom_busmap": - base_network = config_provider("electricity", "base_network")(w) + base_network = config_provider("transmission", "electricity", "base_network")(w) custom_busmap = f"data/busmaps/base_s_{w.clusters}_{base_network}.csv" if mode == "custom_busshapes": - base_network = config_provider("electricity", "base_network")(w) + base_network = config_provider("transmission", "electricity", "base_network")(w) custom_busshapes = f"data/busshapes/base_s_{w.clusters}_{base_network}.geojson" return { diff --git a/rules/development.smk b/rules/development.smk index 88bad702a1..72a2ef3617 100644 --- a/rules/development.smk +++ b/rules/development.smk @@ -54,7 +54,7 @@ rule make_network_comparison: mem_mb=2000, params: countries=config_provider("countries"), - base_network=config_provider("electricity", "base_network"), + base_network=config_provider("transmission", "electricity", "base_network"), compare_to_version=config_provider( "osm_network_release", "compare_to", "version" ), diff --git a/scripts/base_network.py b/scripts/base_network.py index 327a14df21..aef41ca648 100644 --- a/scripts/base_network.py +++ b/scripts/base_network.py @@ -674,7 +674,7 @@ def base_network( parameter_corrections, config, ): - base_network = config["electricity"].get("base_network") + base_network = config["transmission"]["electricity"].get("base_network") osm_version = config["data"]["osm"]["version"] assert base_network in {"entsoegridkit", "osm", "tyndp"}, ( f"base_network must be either 'entsoegridkit', 'osm' or 'tyndp', but got '{base_network}'" @@ -742,7 +742,7 @@ def base_network( n.add("Link", converters.index, **converters) _set_lines_s_nom_from_linetypes(n) - if config["electricity"].get("base_network") == "entsoegridkit": + if config["transmission"]["electricity"].get("base_network") == "entsoegridkit": _apply_parameter_corrections(n, parameter_corrections) n = _remove_unconnected_components(n) diff --git a/scripts/lib/validation/config/electricity.py b/scripts/lib/validation/config/electricity.py index cdcecf0760..72ebcd505f 100644 --- a/scripts/lib/validation/config/electricity.py +++ b/scripts/lib/validation/config/electricity.py @@ -171,10 +171,6 @@ class ElectricityConfig(BaseModel): default_factory=lambda: [220.0, 300.0, 330.0, 380.0, 400.0, 500.0, 750.0], description="Voltage levels to consider.", ) - base_network: Literal["entsoegridkit", "osm", "tyndp"] = Field( - "osm", - description="Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - ) gaslimit_enable: bool = Field( False, description="Add an overall absolute gas limit configured in `electricity: gaslimit`.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index 7550fc0724..e4b8e81bec 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -8,6 +8,8 @@ See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission """ +from typing import Literal + from pydantic import BaseModel, Field @@ -55,6 +57,10 @@ class _TransmissionCarrierConfigElectricity(BaseModel): True, description="Switch for enabling/disabling the electricity transmission grid.", ) + base_network: Literal["entsoegridkit", "osm", "tyndp"] = Field( + "osm", + description="Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + ) transmission_limit: str = Field( "vopt", description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", From bcd519d7d96446facb34f00a35c77231f8a5e756 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 11:00:36 +0200 Subject: [PATCH 12/32] Pre-commit hooks --- scripts/lib/validation/config/electricity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/lib/validation/config/electricity.py b/scripts/lib/validation/config/electricity.py index 72ebcd505f..8c92e554c6 100644 --- a/scripts/lib/validation/config/electricity.py +++ b/scripts/lib/validation/config/electricity.py @@ -8,8 +8,6 @@ See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#electricity """ -from typing import Literal - from pydantic import BaseModel, ConfigDict, Field from scripts.lib.validation.config._base import ConfigModel From 33bc0da4ec5af916e5f1541c1948d24b8878507d Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 14:23:31 +0200 Subject: [PATCH 13/32] Added lines, links, transformers, transmission_projects (now projects) to transmission key. --- config/config.default.yaml | 112 +- ...config.distribution-grid-experimental.yaml | 5 +- config/examples/config.entsoe-all.yaml | 6 +- config/examples/config.iterative.yaml | 1 - config/examples/config.osm-release.yaml | 5 +- config/examples/config.validation.yaml | 13 +- config/schema.default.json | 1092 ++++++++++++----- config/test/config.electricity.yaml | 10 +- config/test/config.tyndp.yaml | 20 +- doc/configuration.rst | 92 +- rules/build_electricity.smk | 69 +- rules/build_sector.smk | 6 +- rules/development.smk | 10 +- scripts/_helpers.py | 8 +- scripts/add_electricity.py | 5 +- scripts/base_network.py | 42 +- scripts/lib/validation/config/_schema.py | 22 - scripts/lib/validation/config/lines.py | 91 -- scripts/lib/validation/config/links.py | 44 - scripts/lib/validation/config/transformers.py | 30 - scripts/lib/validation/config/transmission.py | 182 +++ .../config/transmission_projects.py | 57 - scripts/simplify_network.py | 4 +- test/test_base_network.py | 6 +- 24 files changed, 1161 insertions(+), 771 deletions(-) delete mode 100644 scripts/lib/validation/config/lines.py delete mode 100644 scripts/lib/validation/config/links.py delete mode 100644 scripts/lib/validation/config/transformers.py delete mode 100644 scripts/lib/validation/config/transmission_projects.py diff --git a/config/config.default.yaml b/config/config.default.yaml index 6599913610..3a617d46de 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -188,6 +188,58 @@ transmission: 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: @@ -446,66 +498,6 @@ conventional: nuclear: p_max_pu: data/nuclear_p_max_pu.csv -# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#lines -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 - -# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#links -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 - -# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission_projects -transmission_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 - -# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transformers -transformers: - x: 0.1 - s_nom: 2000.0 - type: "" - # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#load load: fill_gaps: diff --git a/config/examples/config.distribution-grid-experimental.yaml b/config/examples/config.distribution-grid-experimental.yaml index a0d978d211..3399f86093 100644 --- a/config/examples/config.distribution-grid-experimental.yaml +++ b/config/examples/config.distribution-grid-experimental.yaml @@ -50,9 +50,8 @@ transmission: electricity: enable: true base_network: osm - -transmission_projects: - enable: false + projects: + enable: false data: osm: diff --git a/config/examples/config.entsoe-all.yaml b/config/examples/config.entsoe-all.yaml index 6f5627e6ee..2ca21bf5a9 100644 --- a/config/examples/config.entsoe-all.yaml +++ b/config/examples/config.entsoe-all.yaml @@ -31,5 +31,7 @@ electricity: co2limit: 9.59e+7 co2base: 1.918e+9 -lines: - reconnect_crimea: true +transmission: + electricity: + lines: + reconnect_crimea: true diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index cd01a642f0..ad3a6536d2 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -28,7 +28,6 @@ snapshots: end: "2013-03-08" electricity: - extendable_carriers: Generator: [OCGT] StorageUnit: [battery] diff --git a/config/examples/config.osm-release.yaml b/config/examples/config.osm-release.yaml index 14644d66c6..2a7ff4699b 100644 --- a/config/examples/config.osm-release.yaml +++ b/config/examples/config.osm-release.yaml @@ -57,9 +57,8 @@ transmission: electricity: enable: true base_network: osm - -transmission_projects: - enable: false + projects: + enable: false data: osm: diff --git a/config/examples/config.validation.yaml b/config/examples/config.validation.yaml index d48b5295b4..2c45fa4954 100644 --- a/config/examples/config.validation.yaml +++ b/config/examples/config.validation.yaml @@ -61,12 +61,13 @@ conventional: biomass: p_max_pu: 0.65 -lines: - s_max_pu: 0.23 - under_construction: 'remove' - -links: - include_tyndp: false +transmission: + electricity: + lines: + s_max_pu: 0.23 + under_construction: 'remove' + projects: + enable: false costs: year: 2020 diff --git a/config/schema.default.json b/config/schema.default.json index e449fb7152..8f1c656a5e 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -2330,6 +2330,72 @@ } } }, + "ElectricityProjectsConfig": { + "description": "Configuration for `electricity.projects` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", + "type": "boolean" + }, + "include": { + "description": "Configuration for `electricity.projects.include` settings.", + "properties": { + "tyndp2020": { + "default": true, + "description": "Whether to integrate the TYNDP 2020 dataset.", + "type": "boolean" + }, + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" + }, + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } + }, + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } + }, "EnableConfig": { "description": "Configuration for `enable` settings.", "properties": { @@ -5593,6 +5659,235 @@ "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", "type": "string" + }, + "lines": { + "description": "Configuration for `lines` settings.", + "properties": { + "types": { + "additionalProperties": { + "type": "string" + }, + "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + "type": "object" + }, + "s_max_pu": { + "default": 0.7, + "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + "type": "number" + }, + "s_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable line (MW).", + "type": "number" + }, + "max_extension": { + "default": 20000, + "description": "Upper limit for the extended capacity of each extendable line (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + "type": "number" + }, + "reconnect_crimea": { + "default": true, + "description": "Whether to reconnect Crimea to the Ukrainian grid.", + "type": "boolean" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + }, + "dynamic_line_rating": { + "description": "Configuration for `lines.dynamic_line_rating` settings.", + "properties": { + "activate": { + "default": false, + "description": "Whether to take dynamic line rating into account.", + "type": "boolean" + }, + "cutout": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "default", + "description": "Specifies the weather data cutout file(s) to use." + }, + "correction_factor": { + "default": 0.95, + "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + "type": "number" + }, + "max_voltage_difference": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum voltage angle difference in degrees or 'false' to disable." + }, + "max_line_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." + } + } + } + } + }, + "links": { + "description": "Configuration for `links` settings.", + "properties": { + "p_max_pu": { + "default": 1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_min_pu": { + "default": -1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", + "type": "number" + }, + "max_extension": { + "default": 30000, + "description": "Upper limit for the extended capacity of each extendable DC link (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + "type": "number" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + } + } + }, + "transformers": { + "description": "Configuration for `transformers` settings.", + "properties": { + "x": { + "default": 0.1, + "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + "type": "number" + }, + "s_nom": { + "default": 2000.0, + "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + "type": "number" + }, + "type": { + "default": "", + "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + "type": "string" + } + } + }, + "projects": { + "description": "Configuration for `electricity.projects` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", + "type": "boolean" + }, + "include": { + "description": "Configuration for `electricity.projects.include` settings.", + "properties": { + "tyndp2020": { + "default": true, + "description": "Whether to integrate the TYNDP 2020 dataset.", + "type": "boolean" + }, + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" + }, + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } + }, + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } } } }, @@ -5674,72 +5969,6 @@ } } }, - "TransmissionProjectsConfig": { - "description": "Configuration for `transmission_projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `transmission_projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } - }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." - }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", - "enum": [ - "zero", - "keep" - ], - "type": "string" - } - } - }, "_AdjustmentConfig": { "description": "Configuration for adjustment settings (factor/absolute)", "properties": { @@ -6809,7 +7038,7 @@ } }, "_IncludeConfig": { - "description": "Configuration for `transmission_projects.include` settings.", + "description": "Configuration for `electricity.projects.include` settings.", "properties": { "tyndp2020": { "default": true, @@ -8175,30 +8404,259 @@ "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", "type": "string" - } - } - }, - "_TransmissionCarrierConfigGeneral": { - "description": "Configuration for a single transmission carrier.", - "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", - "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", + "lines": { + "description": "Configuration for `lines` settings.", "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" + "types": { + "additionalProperties": { + "type": "string" + }, + "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + "type": "object" }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" + "s_max_pu": { + "default": 0.7, + "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + "type": "number" + }, + "s_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable line (MW).", + "type": "number" + }, + "max_extension": { + "default": 20000, + "description": "Upper limit for the extended capacity of each extendable line (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + "type": "number" + }, + "reconnect_crimea": { + "default": true, + "description": "Whether to reconnect Crimea to the Ukrainian grid.", + "type": "boolean" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + }, + "dynamic_line_rating": { + "description": "Configuration for `lines.dynamic_line_rating` settings.", + "properties": { + "activate": { + "default": false, + "description": "Whether to take dynamic line rating into account.", + "type": "boolean" + }, + "cutout": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "default", + "description": "Specifies the weather data cutout file(s) to use." + }, + "correction_factor": { + "default": 0.95, + "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + "type": "number" + }, + "max_voltage_difference": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum voltage angle difference in degrees or 'false' to disable." + }, + "max_line_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." + } + } + } + } + }, + "links": { + "description": "Configuration for `links` settings.", + "properties": { + "p_max_pu": { + "default": 1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_min_pu": { + "default": -1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", + "type": "number" + }, + "max_extension": { + "default": 30000, + "description": "Upper limit for the extended capacity of each extendable DC link (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + "type": "number" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + } + } + }, + "transformers": { + "description": "Configuration for `transformers` settings.", + "properties": { + "x": { + "default": 0.1, + "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + "type": "number" + }, + "s_nom": { + "default": 2000.0, + "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + "type": "number" + }, + "type": { + "default": "", + "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + "type": "string" + } + } + }, + "projects": { + "description": "Configuration for `electricity.projects` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", + "type": "boolean" + }, + "include": { + "description": "Configuration for `electricity.projects.include` settings.", + "properties": { + "tyndp2020": { + "default": true, + "description": "Whether to integrate the TYNDP 2020 dataset.", + "type": "boolean" + }, + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" + }, + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } + }, + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } + } + } + }, + "_TransmissionCarrierConfigGeneral": { + "description": "Configuration for a single transmission carrier.", + "properties": { + "enable": { + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" + }, + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" } } }, @@ -8928,6 +9386,235 @@ "default": "vopt", "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", "type": "string" + }, + "lines": { + "description": "Configuration for `lines` settings.", + "properties": { + "types": { + "additionalProperties": { + "type": "string" + }, + "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + "type": "object" + }, + "s_max_pu": { + "default": 0.7, + "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + "type": "number" + }, + "s_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable line (MW).", + "type": "number" + }, + "max_extension": { + "default": 20000, + "description": "Upper limit for the extended capacity of each extendable line (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + "type": "number" + }, + "reconnect_crimea": { + "default": true, + "description": "Whether to reconnect Crimea to the Ukrainian grid.", + "type": "boolean" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + }, + "dynamic_line_rating": { + "description": "Configuration for `lines.dynamic_line_rating` settings.", + "properties": { + "activate": { + "default": false, + "description": "Whether to take dynamic line rating into account.", + "type": "boolean" + }, + "cutout": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "default", + "description": "Specifies the weather data cutout file(s) to use." + }, + "correction_factor": { + "default": 0.95, + "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + "type": "number" + }, + "max_voltage_difference": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum voltage angle difference in degrees or 'false' to disable." + }, + "max_line_rating": { + "anyOf": [ + { + "type": "number" + }, + { + "const": false, + "type": "boolean" + } + ], + "default": false, + "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." + } + } + } + } + }, + "links": { + "description": "Configuration for `links` settings.", + "properties": { + "p_max_pu": { + "default": 1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_min_pu": { + "default": -1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", + "type": "number" + }, + "max_extension": { + "default": 30000, + "description": "Upper limit for the extended capacity of each extendable DC link (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + "type": "number" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + } + } + }, + "transformers": { + "description": "Configuration for `transformers` settings.", + "properties": { + "x": { + "default": 0.1, + "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + "type": "number" + }, + "s_nom": { + "default": 2000.0, + "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + "type": "number" + }, + "type": { + "default": "", + "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + "type": "string" + } + } + }, + "projects": { + "description": "Configuration for `electricity.projects` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", + "type": "boolean" + }, + "include": { + "description": "Configuration for `electricity.projects.include` settings.", + "properties": { + "tyndp2020": { + "default": true, + "description": "Whether to integrate the TYNDP 2020 dataset.", + "type": "boolean" + }, + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" + }, + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } + }, + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } } } }, @@ -10295,235 +10982,6 @@ } } }, - "lines": { - "description": "Configuration for `lines` settings.", - "properties": { - "types": { - "additionalProperties": { - "type": "string" - }, - "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", - "type": "object" - }, - "s_max_pu": { - "default": 0.7, - "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - "type": "number" - }, - "s_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable line (MW).", - "type": "number" - }, - "max_extension": { - "default": 20000, - "description": "Upper limit for the extended capacity of each extendable line (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - "type": "number" - }, - "reconnect_crimea": { - "default": true, - "description": "Whether to reconnect Crimea to the Ukrainian grid.", - "type": "boolean" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - }, - "dynamic_line_rating": { - "description": "Configuration for `lines.dynamic_line_rating` settings.", - "properties": { - "activate": { - "default": false, - "description": "Whether to take dynamic line rating into account.", - "type": "boolean" - }, - "cutout": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "default", - "description": "Specifies the weather data cutout file(s) to use." - }, - "correction_factor": { - "default": 0.95, - "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - "type": "number" - }, - "max_voltage_difference": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum voltage angle difference in degrees or 'false' to disable." - }, - "max_line_rating": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." - } - } - } - } - }, - "links": { - "description": "Configuration for `links` settings.", - "properties": { - "p_max_pu": { - "default": 1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_min_pu": { - "default": -1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", - "type": "number" - }, - "max_extension": { - "default": 30000, - "description": "Upper limit for the extended capacity of each extendable DC link (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - "type": "number" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - } - } - }, - "transmission_projects": { - "description": "Configuration for `transmission_projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `transmission_projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } - }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." - }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", - "enum": [ - "zero", - "keep" - ], - "type": "string" - } - } - }, - "transformers": { - "description": "Configuration for `transformers` settings.", - "properties": { - "x": { - "default": 0.1, - "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - "type": "number" - }, - "s_nom": { - "default": 2000.0, - "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - "type": "number" - }, - "type": { - "default": "", - "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", - "type": "string" - } - } - }, "load": { "description": "Configuration for `load` settings.", "properties": { diff --git a/config/test/config.electricity.yaml b/config/test/config.electricity.yaml index 7e13435c5f..ba4e93bf2d 100644 --- a/config/test/config.electricity.yaml +++ b/config/test/config.electricity.yaml @@ -68,10 +68,12 @@ clustering: temporal: resolution_elec: 24h -lines: - dynamic_line_rating: - activate: true - max_line_rating: 1.3 +transmission: + electricity: + lines: + dynamic_line_rating: + activate: true + max_line_rating: 1.3 solving: diff --git a/config/test/config.tyndp.yaml b/config/test/config.tyndp.yaml index e920538f37..e137536ffb 100644 --- a/config/test/config.tyndp.yaml +++ b/config/test/config.tyndp.yaml @@ -22,8 +22,6 @@ snapshots: end: "2013-03-08" electricity: - base_network: tyndp - extendable_carriers: Generator: [OCGT] StorageUnit: [battery] @@ -34,7 +32,16 @@ electricity: transmission: electricity: + base_network: tyndp transmission_limit: v1.0 + links: + p_min_pu: 0 + projects: + enable: true + include: + tyndp2020: false + nep: false + manual: false atlite: default_cutout: europe-2013-03-sarah3-era5 @@ -51,15 +58,6 @@ renewable: max_depth: false min_depth: false -links: - p_min_pu: 0 - -transmission_projects: - include: - tyndp2020: false - nep: false - manual: false - sector: transmission_efficiency: enable: diff --git a/doc/configuration.rst b/doc/configuration.rst index 7f7e532ab4..e5852c045f 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -299,6 +299,32 @@ For non electric carriers, the transmission candidates are built based on Delaunay triangulation and optional Gabriel filtering. The minimum degree specifies the minimum connectedness of the candidate graph. +.. _transmission_projects_cf: + +``electricity.projects`` +------------------------ + +Allows to define additional transmission projects that will be added to the +base network, e.g., from the TYNDP 2020 dataset. The projects are read in +from the CSV files in the subfolder of ``data/transmission_projects/``. New +transmission projects, e.g. from TYNDP 2024, can be added in a new subfolder +of transmission projects, e.g. ``data/transmission_projects/tyndp2024`` while +extending the list of projects in ``transmission.electricity.projects.include`` +in the configuration. The CSV files in the project folder should have the same +columns as the CSV files in the template folder +``data/transmission_projects/template``. + +.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionProjectsConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: projects: + :end-before: hydrogen: + .. jsonschema:: ../config/schema.default.json#/$defs/TransmissionConfig :lift_description: :hide_key: /**/additionalProperties @@ -448,72 +474,6 @@ overwrite the existing values. :start-at: conventional: :end-before: # docs -.. _lines_cf: - -``lines`` -========= - -.. jsonschema:: ../config/schema.default.json#/$defs/LinesConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: lines: - :end-before: # docs - -.. _links_cf: - -``links`` -============= - -.. jsonschema:: ../config/schema.default.json#/$defs/LinksConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: links: - :end-before: # docs - -.. _transmission_projects_cf: - -``transmission_projects`` -========================= - -Allows to define additional transmission projects that will be added to the base network, e.g., from the TYNDP 2020 dataset. The projects are read in from the CSV files in the subfolder of ``data/transmission_projects/``. New transmission projects, e.g. from TYNDP 2024, can be added in a new subfolder of transmission projects, e.g. ``data/transmission_projects/tyndp2024`` while extending the list of ``transmission_projects`` in the ``config.yaml`` by ``tyndp2024``. The CSV files in the project folder should have the same columns as the CSV files in the template folder ``data/transmission_projects/template``. - -.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionProjectsConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: transmission_projects: - :end-before: # docs - -.. _transformers_cf: - -``transformers`` -================ - -.. jsonschema:: ../config/schema.default.json#/$defs/TransformersConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: transformers: - :end-before: # docs - .. _load_cf: ``load`` diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index 8fa29b48de..5373cc799a 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -100,9 +100,9 @@ rule base_network: countries=config_provider("countries"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), - lines=config_provider("lines"), - links=config_provider("links"), - transformers=config_provider("transformers"), + lines=config_provider("transmission", "electricity", "lines"), + links=config_provider("transmission", "electricity", "links"), + transformers=config_provider("transmission", "electricity", "transformers"), clustering=config_provider("clustering", "mode"), admin_levels=config_provider("clustering", "administrative"), message: @@ -477,7 +477,10 @@ rule build_line_rating: input: base_network=resources("networks/base.nc"), cutout=lambda w: input_cutout( - w, config_provider("lines", "dynamic_line_rating", "cutout")(w) + w, + config_provider( + "transmission", "electricity", "lines", "dynamic_line_rating", "cutout" + )(w), ), output: output=resources("dlr.nc"), @@ -504,9 +507,9 @@ rule build_transmission_projects: europe_shape=resources("europe_shape.geojson"), transmission_projects=lambda w: [ "data/transmission_projects/" + name - for name, include in config_provider("transmission_projects", "include")( - w - ).items() + for name, include in config_provider( + "transmission", "electricity", "projects", "include" + )(w).items() if include ], output: @@ -523,9 +526,11 @@ rule build_transmission_projects: resources: mem_mb=4000, params: - transmission_projects=config_provider("transmission_projects"), - line_factor=config_provider("lines", "length_factor"), - s_max_pu=config_provider("lines", "s_max_pu"), + transmission_projects=config_provider("transmission", "electricity", "projects"), + line_factor=config_provider( + "transmission", "electricity", "lines", "length_factor" + ), + s_max_pu=config_provider("transmission", "electricity", "lines", "s_max_pu"), message: "Building transmission projects" script: @@ -537,7 +542,13 @@ rule add_transmission_projects_and_dlr: network=resources("networks/base.nc"), dlr=lambda w: ( resources("dlr.nc") - if config_provider("lines", "dynamic_line_rating", "activate")(w) + if config_provider( + "transmission", + "electricity", + "lines", + "dynamic_line_rating", + "activate", + )(w) else [] ), transmission_projects=lambda w: ( @@ -548,7 +559,7 @@ rule add_transmission_projects_and_dlr: resources("transmission_projects/adjust_lines.csv"), resources("transmission_projects/adjust_links.csv"), ] - if config_provider("transmission_projects", "enable")(w) + if config_provider("transmission", "electricity", "projects", "enable")(w) else [] ), output: @@ -561,9 +572,11 @@ rule add_transmission_projects_and_dlr: resources: mem_mb=4000, params: - transmission_projects=config_provider("transmission_projects"), - dlr=config_provider("lines", "dynamic_line_rating"), - s_max_pu=config_provider("lines", "s_max_pu"), + transmission_projects=config_provider("transmission", "electricity", "projects"), + dlr=config_provider( + "transmission", "electricity", "lines", "dynamic_line_rating" + ), + s_max_pu=config_provider("transmission", "electricity", "lines", "s_max_pu"), message: "Adding transmission projects and dynamic line ratings" script: @@ -677,8 +690,12 @@ rule simplify_network: aggregation_strategies=config_provider( "clustering", "aggregation_strategies", default={} ), - p_max_pu=config_provider("links", "p_max_pu", default=1.0), - p_min_pu=config_provider("links", "p_min_pu", default=-1.0), + p_max_pu=config_provider( + "transmission", "electricity", "links", "p_max_pu", default=1.0 + ), + p_min_pu=config_provider( + "transmission", "electricity", "links", "p_min_pu", default=-1.0 + ), message: "Simplifying network" script: @@ -753,7 +770,9 @@ rule cluster_network: "electricity", "conventional_carriers", default=[] ), max_hours=config_provider("electricity", "max_hours"), - length_factor=config_provider("lines", "length_factor"), + length_factor=config_provider( + "transmission", "electricity", "lines", "length_factor" + ), cluster_mode=config_provider("clustering", "mode"), copperplate_regions=config_provider("clustering", "copperplate_regions"), message: @@ -817,8 +836,12 @@ rule add_electricity: resources: mem_mb=10000, params: - line_length_factor=config_provider("lines", "length_factor"), - link_length_factor=config_provider("links", "length_factor"), + line_length_factor=config_provider( + "transmission", "electricity", "lines", "length_factor" + ), + link_length_factor=config_provider( + "transmission", "electricity", "links", "length_factor" + ), scaling_factor=config_provider("load", "scaling_factor"), countries=config_provider("countries"), snapshots=config_provider("snapshots"), @@ -860,8 +883,8 @@ rule prepare_network: mem_mb=4000, params: time_resolution=config_provider("clustering", "temporal", "resolution_elec"), - links=config_provider("links"), - lines=config_provider("lines"), + links=config_provider("transmission", "electricity", "links"), + lines=config_provider("transmission", "electricity", "lines"), co2base=config_provider("electricity", "co2base"), co2limit_enable=config_provider("electricity", "co2limit_enable", default=False), co2limit=config_provider("electricity", "co2limit"), @@ -960,7 +983,7 @@ rule build_osm_network: params: countries=config_provider("countries"), voltages=config_provider("electricity", "voltages"), - line_types=config_provider("lines", "types"), + line_types=config_provider("transmission", "electricity", "lines", "types"), under_construction=config_provider("osm_network_release", "under_construction"), remove_after=config_provider("osm_network_release", "remove_after"), message: diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 4cae0f8c00..1d9b7b4fb0 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1766,9 +1766,11 @@ rule prepare_sector_network: sector=config_provider("sector"), industry=config_provider("industry"), renewable=config_provider("renewable"), - lines=config_provider("lines"), + lines=config_provider("transmission", "electricity", "lines"), pypsa_eur=config_provider("pypsa_eur"), - length_factor=config_provider("lines", "length_factor"), + length_factor=config_provider( + "transmission", "electricity", "lines", "length_factor" + ), planning_horizons=config_provider("scenario", "planning_horizons"), countries=config_provider("countries"), adjustments=config_provider("adjustments", "sector"), diff --git a/rules/development.smk b/rules/development.smk index 72a2ef3617..809270a0f1 100644 --- a/rules/development.smk +++ b/rules/development.smk @@ -26,9 +26,9 @@ rule base_network_incumbent: countries=config_provider("countries"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), - lines=config_provider("lines"), - links=config_provider("links"), - transformers=config_provider("transformers"), + lines=config_provider("transmission", "electricity", "lines"), + links=config_provider("transmission", "electricity", "links"), + transformers=config_provider("transmission", "electricity", "transformers"), clustering=config_provider("clustering", "mode"), admin_levels=config_provider("clustering", "administrative"), message: @@ -85,7 +85,7 @@ rule prepare_osm_network_release: resources: mem_mb=1000, params: - line_types=config["lines"]["types"], + line_types=config["transmission"]["electricity"]["lines"]["types"], release_version=config_provider("osm_network_release", "release_version"), include_polygons=True, export=True, @@ -108,7 +108,7 @@ rule map_incumbent: resources: mem_mb=1000, params: - line_types=config["lines"]["types"], + line_types=config["transmission"]["electricity"]["lines"]["types"], release_version="Incumbent", include_polygons=False, export=False, diff --git a/scripts/_helpers.py b/scripts/_helpers.py index aea01fe491..2f08afccb8 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -821,8 +821,12 @@ def update_config_from_wildcards(config, w, inplace=True): _, maxext = find_opt(opts, "linemaxext") if maxext is not None: - config["lines"]["max_extension"] = maxext * 1e3 - config["links"]["max_extension"] = maxext * 1e3 + config["transmission"]["electricity"]["lines"]["max_extension"] = ( + maxext * 1e3 + ) + config["transmission"]["electricity"]["links"]["max_extension"] = ( + maxext * 1e3 + ) _, co2l_value = find_opt(opts, "Co2L") if co2l_value is not None: diff --git a/scripts/add_electricity.py b/scripts/add_electricity.py index 84fd226dfd..79a06bd93a 100755 --- a/scripts/add_electricity.py +++ b/scripts/add_electricity.py @@ -14,8 +14,9 @@ ``networks/base_s_{clusters}_elec.nc``. It includes: - today's transmission topology and transfer capacities (optionally including - lines which are under construction according to the config settings ``lines: - under_construction`` and ``links: under_construction``), + lines which are under construction according to the config settings + ``transmission: lines: under_construction`` and + ``links: under_construction``), - today's thermal and hydro power generation capacities (for the technologies listed in the config setting ``electricity: conventional_carriers``), and - today's load time-series (upsampled in a top-down approach according to diff --git a/scripts/base_network.py b/scripts/base_network.py index aef41ca648..e3602e261d 100644 --- a/scripts/base_network.py +++ b/scripts/base_network.py @@ -302,12 +302,12 @@ def _reconnect_crimea(lines): def _set_electrical_parameters_lines_eg(lines, config): v_noms = config["electricity"]["voltages"] - linetypes = config["lines"]["types"] + linetypes = config["transmission"]["electricity"]["lines"]["types"] for v_nom in v_noms: lines.loc[lines["v_nom"] == v_nom, "type"] = linetypes[v_nom] - lines["s_max_pu"] = config["lines"]["s_max_pu"] + lines["s_max_pu"] = config["transmission"]["electricity"]["lines"]["s_max_pu"] return lines @@ -318,7 +318,9 @@ def _set_electrical_parameters_lines_raw(lines, config): return lines v_noms = config["electricity"]["voltages"] - linetypes = _get_linetypes_config(config["lines"]["types"], v_noms) + linetypes = _get_linetypes_config( + config["transmission"]["electricity"]["lines"]["types"], v_noms + ) lines["carrier"] = "AC" lines["dc"] = False @@ -327,7 +329,7 @@ def _set_electrical_parameters_lines_raw(lines, config): lambda x: _get_linetype_by_voltage(x, linetypes) ) - lines["s_max_pu"] = config["lines"]["s_max_pu"] + lines["s_max_pu"] = config["transmission"]["electricity"]["lines"]["s_max_pu"] return lines @@ -345,8 +347,8 @@ def _set_electrical_parameters_links_eg(links, config, links_p_nom): if links.empty: return links - p_max_pu = config["links"].get("p_max_pu", 1.0) - p_min_pu = config["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) + p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) links["p_max_pu"] = p_max_pu links["p_min_pu"] = p_min_pu @@ -378,8 +380,8 @@ def _set_electrical_parameters_links_raw(links, config): if links.empty: return links - p_max_pu = config["links"].get("p_max_pu", 1.0) - p_min_pu = config["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) + p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) links["p_max_pu"] = p_max_pu links["p_min_pu"] = p_min_pu links["carrier"] = "DC" @@ -389,8 +391,8 @@ def _set_electrical_parameters_links_raw(links, config): def _set_electrical_parameters_converters(converters, config): - p_max_pu = config["links"].get("p_max_pu", 1.0) - p_min_pu = config["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) + p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) converters["p_max_pu"] = p_max_pu converters["p_min_pu"] = p_min_pu @@ -407,7 +409,7 @@ def _set_electrical_parameters_converters(converters, config): def _set_electrical_parameters_transformers(transformers, config): - config = config["transformers"] + config = config["transmission"]["electricity"]["transformers"] ## Add transformer parameters transformers["x"] = config.get("x", 0.1) @@ -616,7 +618,9 @@ def _set_links_underwater_fraction(n, offshore_shapes): def _adjust_capacities_of_under_construction_branches(n, config): - lines_mode = config["lines"].get("under_construction", "undef") + lines_mode = config["transmission"]["electricity"]["lines"].get( + "under_construction", "undef" + ) if lines_mode == "zero": n.lines.loc[n.lines.under_construction, "num_parallel"] = 0.0 n.lines.loc[n.lines.under_construction, "s_nom"] = 0.0 @@ -624,17 +628,19 @@ def _adjust_capacities_of_under_construction_branches(n, config): n.remove("Line", n.lines.index[n.lines.under_construction]) elif lines_mode != "keep": logger.warning( - "Unrecognized configuration for `lines: under_construction` = `{}`. Keeping under construction lines." + "Unrecognized configuration for `transmission: lines: under_construction` = `{}`. Keeping under construction lines." ) - links_mode = config["links"].get("under_construction", "undef") + links_mode = config["transmission"]["electricity"]["links"].get( + "under_construction", "undef" + ) if links_mode == "zero": n.links.loc[n.links.under_construction, "p_nom"] = 0.0 elif links_mode == "remove": n.remove("Link", n.links.index[n.links.under_construction]) elif links_mode != "keep": logger.warning( - "Unrecognized configuration for `links: under_construction` = `{}`. Keeping under construction links." + "Unrecognized configuration for `transmission: links: under_construction` = `{}`. Keeping under construction links." ) if lines_mode == "remove" or links_mode == "remove": @@ -701,9 +707,9 @@ def base_network( converters = _load_converters_from_eg(buses, converters) # Optionally reconnect Crimea - if (config["lines"].get("reconnect_crimea", True)) & ( - "UA" in config["countries"] - ): + if ( + config["transmission"]["electricity"]["lines"].get("reconnect_crimea", True) + ) & ("UA" in config["countries"]): lines = _reconnect_crimea(lines) # Set electrical parameters of lines and links diff --git a/scripts/lib/validation/config/_schema.py b/scripts/lib/validation/config/_schema.py index 7feb8b1a3c..471656bb5a 100644 --- a/scripts/lib/validation/config/_schema.py +++ b/scripts/lib/validation/config/_schema.py @@ -23,8 +23,6 @@ from scripts.lib.validation.config.existing_capacities import ExistingCapacitiesConfig from scripts.lib.validation.config.foresight import ForesightConfig from scripts.lib.validation.config.industry import IndustryConfig -from scripts.lib.validation.config.lines import LinesConfig -from scripts.lib.validation.config.links import LinksConfig from scripts.lib.validation.config.load import LoadConfig from scripts.lib.validation.config.overpass_api import OverpassApiConfig from scripts.lib.validation.config.pypsa_eur import PypsaEurConfig @@ -35,11 +33,7 @@ from scripts.lib.validation.config.snapshots import SnapshotsConfig from scripts.lib.validation.config.solar_thermal import SolarThermalConfig from scripts.lib.validation.config.solving import SolvingConfig -from scripts.lib.validation.config.transformers import TransformersConfig from scripts.lib.validation.config.transmission import TransmissionConfig -from scripts.lib.validation.config.transmission_projects import ( - TransmissionProjectsConfig, -) class LoggingConfig(ConfigModel): @@ -149,22 +143,6 @@ class ConfigSchema(BaseModel): default_factory=ConventionalConfig, description="Conventional power plants configuration.", ) - lines: LinesConfig = Field( - default_factory=LinesConfig, - description="Transmission lines configuration.", - ) - links: LinksConfig = Field( - default_factory=LinksConfig, - description="HVDC links configuration.", - ) - transmission_projects: TransmissionProjectsConfig = Field( - default_factory=TransmissionProjectsConfig, - description="Transmission projects configuration.", - ) - transformers: TransformersConfig = Field( - default_factory=TransformersConfig, - description="Transformers configuration.", - ) load: LoadConfig = Field( default_factory=LoadConfig, description="Electrical load configuration.", diff --git a/scripts/lib/validation/config/lines.py b/scripts/lib/validation/config/lines.py deleted file mode 100644 index 7ef71cc85c..0000000000 --- a/scripts/lib/validation/config/lines.py +++ /dev/null @@ -1,91 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Lines configuration. - -See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#lines -""" - -from typing import Literal - -from pydantic import BaseModel, Field - -from scripts.lib.validation.config._base import ConfigModel - - -class _DynamicLineRatingConfig(ConfigModel): - """Configuration for `lines.dynamic_line_rating` settings.""" - - activate: bool = Field( - False, - description="Whether to take dynamic line rating into account.", - ) - cutout: str | list[str] = Field( - "default", - description="Specifies the weather data cutout file(s) to use.", - ) - correction_factor: float = Field( - 0.95, - description="Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - ) - max_voltage_difference: float | Literal[False] = Field( - False, - description="Maximum voltage angle difference in degrees or 'false' to disable.", - ) - max_line_rating: float | Literal[False] = Field( - False, - description="Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable.", - ) - - -class LinesConfig(BaseModel): - """Configuration for `lines` settings.""" - - types: dict[float, str] = Field( - default_factory=lambda: { - 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", - }, - description="Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", - ) - s_max_pu: float = Field( - 0.7, - description="Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - ) - s_nom_max: float = Field( - float("inf"), - description="Global upper limit for the maximum capacity of each extendable line (MW).", - ) - max_extension: float = Field( - 20000, - description="Upper limit for the extended capacity of each extendable line (MW).", - ) - length_factor: float = Field( - 1.25, - description="Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - ) - reconnect_crimea: bool = Field( - True, - description="Whether to reconnect Crimea to the Ukrainian grid.", - ) - under_construction: Literal["zero", "remove", "keep"] = Field( - "keep", - description="Specifies how to handle lines which are currently under construction.", - ) - dynamic_line_rating: _DynamicLineRatingConfig = Field( - default_factory=_DynamicLineRatingConfig, - description="Configuration for dynamic line rating.", - ) diff --git a/scripts/lib/validation/config/links.py b/scripts/lib/validation/config/links.py deleted file mode 100644 index a994c20af6..0000000000 --- a/scripts/lib/validation/config/links.py +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Links configuration. - -See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#links -""" - -from typing import Literal - -from pydantic import Field - -from scripts.lib.validation.config._base import ConfigModel - - -class LinksConfig(ConfigModel): - """Configuration for `links` settings.""" - - p_max_pu: float = Field( - 1.0, - description="Correction factor for link capacities `p_nom`.", - ) - p_min_pu: float = Field( - -1.0, - description="Correction factor for link capacities `p_nom`.", - ) - p_nom_max: float = Field( - float("inf"), - description="Global upper limit for the maximum capacity of each extendable DC link (MW).", - ) - max_extension: float = Field( - 30000, - description="Upper limit for the extended capacity of each extendable DC link (MW).", - ) - length_factor: float = Field( - 1.25, - description="Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - ) - under_construction: Literal["zero", "remove", "keep"] = Field( - "keep", - description="Specifies how to handle lines which are currently under construction.", - ) diff --git a/scripts/lib/validation/config/transformers.py b/scripts/lib/validation/config/transformers.py deleted file mode 100644 index 207981250f..0000000000 --- a/scripts/lib/validation/config/transformers.py +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Transformers configuration. - -See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transformers -""" - -from pydantic import Field - -from scripts.lib.validation.config._base import ConfigModel - - -class TransformersConfig(ConfigModel): - """Configuration for `transformers` settings.""" - - x: float = Field( - 0.1, - description="Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - ) - s_nom: float = Field( - 2000.0, - description="Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - ) - type: str = Field( - "", - description="Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", - ) diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index e4b8e81bec..bbb7d75f27 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -12,6 +12,172 @@ from pydantic import BaseModel, Field +from scripts.lib.validation.config._base import ConfigModel + + +class _DynamicLineRatingConfig(ConfigModel): + """Configuration for `lines.dynamic_line_rating` settings.""" + + activate: bool = Field( + False, + description="Whether to take dynamic line rating into account.", + ) + cutout: str | list[str] = Field( + "default", + description="Specifies the weather data cutout file(s) to use.", + ) + correction_factor: float = Field( + 0.95, + description="Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + ) + max_voltage_difference: float | Literal[False] = Field( + False, + description="Maximum voltage angle difference in degrees or 'false' to disable.", + ) + max_line_rating: float | Literal[False] = Field( + False, + description="Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable.", + ) + + +class LinesConfig(BaseModel): + """Configuration for `lines` settings.""" + + types: dict[float, str] = Field( + default_factory=lambda: { + 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", + }, + description="Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + ) + s_max_pu: float = Field( + 0.7, + description="Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + ) + s_nom_max: float = Field( + float("inf"), + description="Global upper limit for the maximum capacity of each extendable line (MW).", + ) + max_extension: float = Field( + 20000, + description="Upper limit for the extended capacity of each extendable line (MW).", + ) + length_factor: float = Field( + 1.25, + description="Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + ) + reconnect_crimea: bool = Field( + True, + description="Whether to reconnect Crimea to the Ukrainian grid.", + ) + under_construction: Literal["zero", "remove", "keep"] = Field( + "keep", + description="Specifies how to handle lines which are currently under construction.", + ) + dynamic_line_rating: _DynamicLineRatingConfig = Field( + default_factory=_DynamicLineRatingConfig, + description="Configuration for dynamic line rating.", + ) + + +class LinksConfig(ConfigModel): + """Configuration for `links` settings.""" + + p_max_pu: float = Field( + 1.0, + description="Correction factor for link capacities `p_nom`.", + ) + p_min_pu: float = Field( + -1.0, + description="Correction factor for link capacities `p_nom`.", + ) + p_nom_max: float = Field( + float("inf"), + description="Global upper limit for the maximum capacity of each extendable DC link (MW).", + ) + max_extension: float = Field( + 30000, + description="Upper limit for the extended capacity of each extendable DC link (MW).", + ) + length_factor: float = Field( + 1.25, + description="Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + ) + under_construction: Literal["zero", "remove", "keep"] = Field( + "keep", + description="Specifies how to handle lines which are currently under construction.", + ) + + +class TransformersConfig(ConfigModel): + """Configuration for `transformers` settings.""" + + x: float = Field( + 0.1, + description="Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + ) + s_nom: float = Field( + 2000.0, + description="Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + ) + type: str = Field( + "", + description="Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + ) + + +class _IncludeConfig(ConfigModel): + """Configuration for `electricity.projects.include` settings.""" + + tyndp2020: bool = Field( + True, + description="Whether to integrate the TYNDP 2020 dataset.", + ) + nep: bool = Field( + True, + description="Whether to integrate the German network development plan dataset.", + ) + manual: bool = Field( + True, + description="Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + ) + + +class ElectricityProjectsConfig(BaseModel): + """Configuration for `electricity.projects` settings.""" + + enable: bool = Field( + True, + description="Whether to integrate this transmission projects or not.", + ) + include: _IncludeConfig = Field( + default_factory=_IncludeConfig, + description="Name of the transmission projects. They should be unique and have to be provided in the `data/transmission_projects` folder.", + ) + skip: list[str] = Field( + default_factory=lambda: ["upgraded_lines", "upgraded_links"], + description="Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + ) + status: list[str] | dict[str, list[str]] = Field( + default_factory=lambda: ["under_construction", "in_permitting", "confirmed"], + description="Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`.", + ) + new_link_capacity: Literal["zero", "keep"] = Field( + "zero", + description="Whether to set the new link capacity to the provided capacity or set it to zero.", + ) + class _GabrielFilterConfig(BaseModel): """Configuration for `transmission..gabriel_filter` settings.""" @@ -65,6 +231,22 @@ class _TransmissionCarrierConfigElectricity(BaseModel): "vopt", description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", ) + lines: LinesConfig = Field( + default_factory=LinesConfig, + description="Transmission lines configuration.", + ) + links: LinksConfig = Field( + default_factory=LinksConfig, + description="HVDC links configuration.", + ) + transformers: TransformersConfig = Field( + default_factory=TransformersConfig, + description="Transformers configuration.", + ) + projects: ElectricityProjectsConfig = Field( + default_factory=ElectricityProjectsConfig, + description="Electricity transmission projects configuration.", + ) class TransmissionConfig(BaseModel): diff --git a/scripts/lib/validation/config/transmission_projects.py b/scripts/lib/validation/config/transmission_projects.py deleted file mode 100644 index e48a790a98..0000000000 --- a/scripts/lib/validation/config/transmission_projects.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Transmission projects configuration. - -See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission-projects -""" - -from typing import Literal - -from pydantic import BaseModel, Field - -from scripts.lib.validation.config._base import ConfigModel - - -class _IncludeConfig(ConfigModel): - """Configuration for `transmission_projects.include` settings.""" - - tyndp2020: bool = Field( - True, - description="Whether to integrate the TYNDP 2020 dataset.", - ) - nep: bool = Field( - True, - description="Whether to integrate the German network development plan dataset.", - ) - manual: bool = Field( - True, - description="Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - ) - - -class TransmissionProjectsConfig(BaseModel): - """Configuration for `transmission_projects` settings.""" - - enable: bool = Field( - True, - description="Whether to integrate this transmission projects or not.", - ) - include: _IncludeConfig = Field( - default_factory=_IncludeConfig, - description="Name of the transmission projects. They should be unique and have to be provided in the `data/transmission_projects` folder.", - ) - skip: list[str] = Field( - default_factory=lambda: ["upgraded_lines", "upgraded_links"], - description="Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - ) - status: list[str] | dict[str, list[str]] = Field( - default_factory=lambda: ["under_construction", "in_permitting", "confirmed"], - description="Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`.", - ) - new_link_capacity: Literal["zero", "keep"] = Field( - "zero", - description="Whether to set the new link capacity to the provided capacity or set it to zero.", - ) diff --git a/scripts/simplify_network.py b/scripts/simplify_network.py index 6ba67f0700..fc94c4c941 100644 --- a/scripts/simplify_network.py +++ b/scripts/simplify_network.py @@ -432,7 +432,9 @@ def remove_converters(n: pypsa.Network) -> pypsa.Network: Nyears = n.snapshot_weightings.objective.sum() / 8760 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"][ + 380 + ] n, trafo_map = simplify_network_to_380(n, linetype_380) busmaps = [trafo_map] diff --git a/test/test_base_network.py b/test/test_base_network.py index 905c51faea..5131f3b41e 100644 --- a/test/test_base_network.py +++ b/test/test_base_network.py @@ -82,7 +82,11 @@ def test_get_linetype_by_voltage(config): line_type_list = [] for v_nom in v_nom_list: - line_type_list.append(_get_linetype_by_voltage(v_nom, config["lines"]["types"])) + line_type_list.append( + _get_linetype_by_voltage( + v_nom, config["transmission"]["electricity"]["lines"]["types"] + ) + ) assert len(line_type_list) == len(reference_list) assert all([x == y for x, y in zip(line_type_list, reference_list)]) From 6adb27dba8e4fb4398391ed92d6e9625eb93cdb8 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 21 Apr 2026 14:49:32 +0200 Subject: [PATCH 14/32] Added gas_network (now methane_gas) to transmission key. --- Snakefile | 2 +- config/config.default.yaml | 3 +- config/examples/config.iterative.yaml | 3 +- config/schema.default.json | 44 ++++++++++++++----- config/test/config.overnight.yaml | 3 +- scripts/lib/validation/config/sector.py | 4 -- scripts/lib/validation/config/transmission.py | 11 +++++ scripts/prepare_sector_network.py | 32 ++++++++------ 8 files changed, 68 insertions(+), 34 deletions(-) diff --git a/Snakefile b/Snakefile index 8e18e8756c..58a0d2a6c1 100644 --- a/Snakefile +++ b/Snakefile @@ -127,7 +127,7 @@ rule all: ( RESULTS + "maps/static/base_s_{clusters}_{opts}_{sector_opts}-ch4_network_{planning_horizons}.pdf" - if config_provider("sector", "gas_network")(w) + if config_provider("transmission", "methane_gas", "enable")(w) else [] ), run=config["run"]["name"], diff --git a/config/config.default.yaml b/config/config.default.yaml index 3a617d46de..4e11dc03ea 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -254,6 +254,8 @@ transmission: min_degree: 1 length_factor: 1.25 cost_factor: 1 + methane_gas: + enable: true # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite atlite: @@ -929,7 +931,6 @@ sector: compression_per_1000km: 0.01 electricity distribution grid: efficiency_static: 0.97 - gas_network: true H2_retrofit: false H2_retrofit_capacity_per_CH4: 0.6 gas_network_connectivity_upgrade: 1 diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index ad3a6536d2..19a2e4b2d0 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -39,6 +39,8 @@ electricity: transmission: carbon_dioxide: enable: false + methane_gas: + enable: true atlite: default_cutout: be-03-2013-era5 @@ -63,7 +65,6 @@ clustering: resolution_sector: 24h sector: - gas_network: true H2_retrofit: true district_heating: ptes: diff --git a/config/schema.default.json b/config/schema.default.json index 8f1c656a5e..53a51bc4a9 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4925,12 +4925,6 @@ } } }, - "gas_network": { - "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "type": "boolean" - }, "H2_retrofit": { "default": false, "description": "Add option for retrofiting existing pipelines to transport hydrogen.", @@ -5966,6 +5960,17 @@ "type": "number" } } + }, + "methane_gas": { + "properties": { + "enable": { + "default": true, + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "type": "boolean" + } + }, + "description": "Configuration for methane gas transmission candidates." } } }, @@ -8636,6 +8641,16 @@ } } }, + "_TransmissionCarrierConfigGas": { + "properties": { + "enable": { + "default": true, + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "type": "boolean" + } + } + }, "_TransmissionCarrierConfigGeneral": { "description": "Configuration for a single transmission carrier.", "properties": { @@ -9693,6 +9708,17 @@ "type": "number" } } + }, + "methane_gas": { + "properties": { + "enable": { + "default": true, + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "type": "boolean" + } + }, + "description": "Configuration for methane gas transmission candidates." } } }, @@ -12017,12 +12043,6 @@ } } }, - "gas_network": { - "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "type": "boolean" - }, "H2_retrofit": { "default": false, "description": "Add option for retrofiting existing pipelines to transport hydrogen.", diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index 69c7d431cb..797c9fffe2 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -39,6 +39,8 @@ electricity: transmission: carbon_dioxide: enable: false + methane_gas: + enable: true atlite: default_cutout: be-03-2013-era5 @@ -63,7 +65,6 @@ clustering: resolution_sector: 24h sector: - gas_network: true H2_retrofit: true residential_heat: dsm: diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index 1cf1f4c976..a0329cbbce 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -817,10 +817,6 @@ class SectorConfig(BaseModel): description="Transmission efficiency configuration.", ) - gas_network: bool = Field( - True, - description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - ) H2_retrofit: bool = Field( False, description="Add option for retrofiting existing pipelines to transport hydrogen.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index bbb7d75f27..b3c4d4712b 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -249,6 +249,13 @@ class _TransmissionCarrierConfigElectricity(BaseModel): ) +class _TransmissionCarrierConfigGas(BaseModel): + enable: bool = Field( + True, + description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + ) + + class TransmissionConfig(BaseModel): """Configuration for `transmission` settings.""" @@ -264,3 +271,7 @@ class TransmissionConfig(BaseModel): default_factory=_TransmissionCarrierConfigGeneral, description="Configuration for carbon dioxide transmission candidates.", ) + methane_gas: _TransmissionCarrierConfigGas = Field( + default_factory=_TransmissionCarrierConfigGas, + description="Configuration for methane gas transmission candidates.", + ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 70cea9f411..1666cc31da 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -110,7 +110,7 @@ def define_spatial(nodes, options, cf_transmission): spatial.gas = SimpleNamespace() - if options["gas_network"]: + if cf_transmission["methane_gas"]["enable"]: spatial.gas.nodes = nodes + " gas" spatial.gas.locations = nodes spatial.gas.biogas = nodes + " biogas" @@ -1758,13 +1758,12 @@ def add_h2_gas_infrastructure( pop_layout, h2_cavern_file, h2_transmission_candidates, - h2_transmission_enable, cavern_types, clustered_gas_network_file, gas_input_nodes, spatial, options, - h2_transmission_cost_factor=1.0, + cf_transmission, ): """ Add hydrogen and gas infrastructure to the network. @@ -1781,8 +1780,6 @@ def add_h2_gas_infrastructure( Path to CSV file containing hydrogen cavern storage potentials h2_transmission_candidates : str Path to GeoJSON file containing hydrogen pipeline candidates - h2_transmission_enable : bool - Whether hydrogen transmission topology is enabled in config cavern_types : list List of underground storage types to consider clustered_gas_network_file : str, optional @@ -1805,8 +1802,8 @@ def add_h2_gas_infrastructure( - SMR : bool - min_part_load_methanation : float - cc_fraction : float - h2_transmission_cost_factor : float, optional - Factor to scale the capital costs of the H2 network, default 1.0 + cf_transmission : dict + Dictionary of configuration options for transmission infrastructure. Returns ------- @@ -1933,7 +1930,7 @@ def add_h2_gas_infrastructure( if options["H2_retrofit"]: gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) - if options["gas_network"]: + if cf_transmission["methane_gas"]["enable"]: logger.info( "Add natural gas infrastructure, incl. LNG terminals, production, storage and entry-points." ) @@ -2081,7 +2078,7 @@ def add_h2_gas_infrastructure( ) # TODO: implement offshore costs, using 1.5-2.0x multiplier - if h2_transmission_enable: + if cf_transmission["hydrogen"]["enable"]: logger.info("Add options for new hydrogen pipelines.") h2_pipes = gpd.read_file(h2_transmission_candidates).set_index("name") @@ -2100,7 +2097,7 @@ def add_h2_gas_infrastructure( length=h2_pipes["length"].values, capital_cost=costs.at["H2 (g) pipeline", "capital_cost"] * h2_pipes["length"].values - * h2_transmission_cost_factor, + * cf_transmission["hydrogen"]["cost_factor"], carrier="H2 pipeline", lifetime=costs.at["H2 (g) pipeline", "lifetime"], ) @@ -3760,6 +3757,7 @@ def add_biomass( options, spatial, cf_industry, + cf_transmission, pop_layout, biomass_potentials_file, biomass_transport_costs_file=None, @@ -3790,6 +3788,8 @@ def add_biomass( Object containing spatial information about different carriers (gas, biomass, etc.) cf_industry : dict Dictionary containing industrial sector configuration + cf_transmission : dict + Dictionary of configuration options for transmission infrastructure. pop_layout : pd.DataFrame DataFrame containing population layout information biomass_potentials_file : str @@ -3821,7 +3821,7 @@ def add_biomass( biomass_potentials = pd.read_csv(biomass_potentials_file, index_col=0) * nyears # need to aggregate potentials if gas not nodally resolved - if options["gas_network"]: + if cf_transmission["methane_gas"]["enable"]: biogas_potentials_spatial = biomass_potentials["biogas"].rename( index=lambda x: x + " biogas" ) @@ -4489,6 +4489,7 @@ def add_industry( options: dict, spatial: SimpleNamespace, cf_industry: dict, + cf_transmission: dict, investment_year: int, co2_transmission_enable: bool, ): @@ -4521,6 +4522,8 @@ def add_industry( (biomass, gas, oil, methanol, etc.) cf_industry : dict Industry-specific configuration parameters + cf_transmission : dict + Dictionary of configuration options for transmission infrastructure. investment_year : int Year for which investment costs should be considered HeatSystem : Enum @@ -4654,7 +4657,7 @@ def add_industry( gas_demand = industrial_demand.loc[nodes, "methane"] / nhours - if options["gas_network"]: + if cf_transmission["methane_gas"]["enable"]: spatial_gas_demand = gas_demand.rename(index=lambda x: x + " gas for industry") else: spatial_gas_demand = gas_demand.sum() @@ -6356,13 +6359,12 @@ def add_import_options( pop_layout=pop_layout, h2_cavern_file=snakemake.input.h2_cavern, h2_transmission_candidates=snakemake.input.h2_transmission_candidates, - h2_transmission_enable=cf_transmission["hydrogen"]["enable"], cavern_types=snakemake.params.sector["hydrogen_underground_storage_locations"], clustered_gas_network_file=snakemake.input.clustered_gas_network, gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, - h2_transmission_cost_factor=cf_transmission["hydrogen"]["cost_factor"], + cf_transmission=cf_transmission, ) # Hydrogen already implemented in add_h2_gas_infrastructure @@ -6445,6 +6447,7 @@ def add_import_options( options=options, spatial=spatial, cf_industry=cf_industry, + cf_transmission=cf_transmission, pop_layout=pop_layout, biomass_potentials_file=snakemake.input.biomass_potentials, biomass_transport_costs_file=snakemake.input.biomass_transport_costs, @@ -6467,6 +6470,7 @@ def add_import_options( options=options, spatial=spatial, cf_industry=cf_industry, + cf_transmission=cf_transmission, investment_year=investment_year, co2_transmission_enable=cf_transmission["carbon_dioxide"]["enable"], ) From 833260f1bc8e790cf6d73531b68e4625299b7372 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Wed, 22 Apr 2026 17:20:56 +0200 Subject: [PATCH 15/32] Renamed methane_gas back to gas. --- Snakefile | 2 +- config/config.default.yaml | 2 +- config/examples/config.iterative.yaml | 2 +- config/schema.default.json | 4 ++-- config/test/config.overnight.yaml | 2 +- scripts/lib/validation/config/transmission.py | 2 +- scripts/prepare_sector_network.py | 8 ++++---- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Snakefile b/Snakefile index 58a0d2a6c1..37b90ce99e 100644 --- a/Snakefile +++ b/Snakefile @@ -127,7 +127,7 @@ rule all: ( RESULTS + "maps/static/base_s_{clusters}_{opts}_{sector_opts}-ch4_network_{planning_horizons}.pdf" - if config_provider("transmission", "methane_gas", "enable")(w) + if config_provider("transmission", "gas", "enable")(w) else [] ), run=config["run"]["name"], diff --git a/config/config.default.yaml b/config/config.default.yaml index 4e11dc03ea..f89e9b7ec1 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -254,7 +254,7 @@ transmission: min_degree: 1 length_factor: 1.25 cost_factor: 1 - methane_gas: + gas: enable: true # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index 19a2e4b2d0..b8c7374ada 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -39,7 +39,7 @@ electricity: transmission: carbon_dioxide: enable: false - methane_gas: + gas: enable: true atlite: diff --git a/config/schema.default.json b/config/schema.default.json index 53a51bc4a9..2e0fe5a65a 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5961,7 +5961,7 @@ } } }, - "methane_gas": { + "gas": { "properties": { "enable": { "default": true, @@ -9709,7 +9709,7 @@ } } }, - "methane_gas": { + "gas": { "properties": { "enable": { "default": true, diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index 797c9fffe2..4a4f292d6a 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -39,7 +39,7 @@ electricity: transmission: carbon_dioxide: enable: false - methane_gas: + gas: enable: true atlite: diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index b3c4d4712b..f35b8ff67d 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -271,7 +271,7 @@ class TransmissionConfig(BaseModel): default_factory=_TransmissionCarrierConfigGeneral, description="Configuration for carbon dioxide transmission candidates.", ) - methane_gas: _TransmissionCarrierConfigGas = Field( + gas: _TransmissionCarrierConfigGas = Field( default_factory=_TransmissionCarrierConfigGas, description="Configuration for methane gas transmission candidates.", ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 1666cc31da..1923c588e3 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -110,7 +110,7 @@ def define_spatial(nodes, options, cf_transmission): spatial.gas = SimpleNamespace() - if cf_transmission["methane_gas"]["enable"]: + if cf_transmission["gas"]["enable"]: spatial.gas.nodes = nodes + " gas" spatial.gas.locations = nodes spatial.gas.biogas = nodes + " biogas" @@ -1930,7 +1930,7 @@ def add_h2_gas_infrastructure( if options["H2_retrofit"]: gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) - if cf_transmission["methane_gas"]["enable"]: + if cf_transmission["gas"]["enable"]: logger.info( "Add natural gas infrastructure, incl. LNG terminals, production, storage and entry-points." ) @@ -3821,7 +3821,7 @@ def add_biomass( biomass_potentials = pd.read_csv(biomass_potentials_file, index_col=0) * nyears # need to aggregate potentials if gas not nodally resolved - if cf_transmission["methane_gas"]["enable"]: + if cf_transmission["gas"]["enable"]: biogas_potentials_spatial = biomass_potentials["biogas"].rename( index=lambda x: x + " biogas" ) @@ -4657,7 +4657,7 @@ def add_industry( gas_demand = industrial_demand.loc[nodes, "methane"] / nhours - if cf_transmission["methane_gas"]["enable"]: + if cf_transmission["gas"]["enable"]: spatial_gas_demand = gas_demand.rename(index=lambda x: x + " gas for industry") else: spatial_gas_demand = gas_demand.sum() From ec5756f8e119b2b7d90b79f1f8587572ade11490 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Thu, 23 Apr 2026 15:35:50 +0200 Subject: [PATCH 16/32] Refactored code do build transmission topology based on min_degrees instead of carriers. --- rules/build_electricity.smk | 20 ++-- rules/build_sector.smk | 92 +++++++++------ scripts/build_transmission_projects.py | 10 +- scripts/build_transmission_topology.py | 150 +++++++++++++------------ scripts/prepare_sector_network.py | 66 ++++++++--- 5 files changed, 200 insertions(+), 138 deletions(-) diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index 5373cc799a..39ffc16747 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -513,11 +513,11 @@ rule build_transmission_projects: if include ], output: - new_lines=resources("transmission_projects/new_lines.csv"), - new_links=resources("transmission_projects/new_links.csv"), - adjust_lines=resources("transmission_projects/adjust_lines.csv"), - adjust_links=resources("transmission_projects/adjust_links.csv"), - new_buses=resources("transmission_projects/new_buses.csv"), + new_lines=resources("transmission/electricity_projects/new_lines.csv"), + new_links=resources("transmission/electricity_projects/new_links.csv"), + adjust_lines=resources("transmission/electricity_projects/adjust_lines.csv"), + adjust_links=resources("transmission/electricity_projects/adjust_links.csv"), + new_buses=resources("transmission/electricity_projects/new_buses.csv"), log: logs("build_transmission_projects.log"), benchmark: @@ -553,11 +553,11 @@ rule add_transmission_projects_and_dlr: ), transmission_projects=lambda w: ( [ - resources("transmission_projects/new_buses.csv"), - resources("transmission_projects/new_lines.csv"), - resources("transmission_projects/new_links.csv"), - resources("transmission_projects/adjust_lines.csv"), - resources("transmission_projects/adjust_links.csv"), + resources("transmission/electricity_projects/new_buses.csv"), + resources("transmission/electricity_projects/new_lines.csv"), + resources("transmission/electricity_projects/new_links.csv"), + resources("transmission/electricity_projects/adjust_lines.csv"), + resources("transmission/electricity_projects/adjust_links.csv"), ] if config_provider("transmission", "electricity", "projects", "enable")(w) else [] diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 1d9b7b4fb0..34ac2496f4 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -146,44 +146,52 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") +def transmission_candidate_min_degrees() -> list[int]: + min_degrees = set() + + for carrier_config in config.get("transmission", {}).values(): + if not isinstance(carrier_config, dict): + continue + if not carrier_config.get("enable", False): + continue + gabriel_filter = carrier_config.get("gabriel_filter", {}) + if not isinstance(gabriel_filter, dict): + continue + if not gabriel_filter.get("enable", False): + continue + if isinstance(gabriel_filter, dict) and "min_degree" in gabriel_filter: + min_degrees.add(int(gabriel_filter["min_degree"])) + + return sorted(min_degrees) + + rule build_transmission_topology: input: network=resources("networks/base_s_{clusters}.nc"), offshore_shapes=resources("offshore_shapes.geojson"), output: - all_edges=resources("transmission/{carrier}_all_edges_{clusters}.geojson"), - candidates=resources("transmission/{carrier}_candidates_{clusters}.geojson"), + all_edges=resources("transmission/all_edges_{clusters}.geojson"), + candidates=expand( + resources("transmission/candidates_{{clusters}}_min_{min_degree}.geojson"), + min_degree=transmission_candidate_min_degrees(), + ), log: - logs("build_transmission_topology_{carrier}_{clusters}.log"), + logs("build_transmission_topology_{clusters}.log"), benchmark: - benchmarks("build_transmission_topology/{carrier}_{clusters}") - wildcard_constraints: - carrier="hydrogen|carbon_dioxide", + benchmarks("build_transmission_topology/{clusters}") resources: mem_mb=4000, params: - carrier_enabled=lambda w: config_provider("transmission", w.carrier, "enable")( - w - ), - gabriel_filter_enabled=lambda w: config_provider( - "transmission", - w.carrier, - "gabriel_filter", - "enable", - )(w), - min_degree=lambda w: config_provider( - "transmission", - w.carrier, - "gabriel_filter", - "min_degree", - )(w), - length_factor=lambda w: config_provider( - "transmission", - w.carrier, - "length_factor", - )(w), + transmission=config_provider("transmission"), + min_degrees=transmission_candidate_min_degrees(), + candidate_outputs_by_min_degree=lambda w: { + int(min_degree): resources( + f"transmission/candidates_{w.clusters}_min_{int(min_degree)}.geojson" + ) + for min_degree in transmission_candidate_min_degrees() + }, message: - "Building transmission candidates for {wildcards.carrier} and {wildcards.clusters} clusters" + "Building transmission candidates for {wildcards.clusters} clusters" script: scripts("build_transmission_topology.py") @@ -1604,22 +1612,32 @@ def input_heat_source_power(w): } +def input_transmission_candidates(w): + """Generate transmission candidate file inputs for enabled transmission carriers.""" + candidates = {} + 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: + candidates[key] = resources( + f"transmission/candidates_{{clusters}}_min_{gabriel_cfg['min_degree']}.geojson" + ) + else: + candidates[key] = resources("transmission/all_edges_{clusters}.geojson") + return candidates + + rule prepare_sector_network: input: unpack(input_profile_offwind), unpack(input_heat_source_power), + unpack(input_transmission_candidates), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, - co2_transmission_candidates=lambda w: ( - resources("transmission/carbon_dioxide_candidates_{clusters}.geojson") - if config_provider("transmission", "carbon_dioxide", "enable")(w) - else [] - ), - h2_transmission_candidates=lambda w: ( - resources("transmission/hydrogen_candidates_{clusters}.geojson") - if config_provider("transmission", "hydrogen", "enable")(w) - else [] - ), snapshot_weightings=resources( "snapshot_weightings_base_s_{clusters}_elec_{opts}_{sector_opts}.csv" ), diff --git a/scripts/build_transmission_projects.py b/scripts/build_transmission_projects.py index e5c540c710..c8b6d7595a 100644 --- a/scripts/build_transmission_projects.py +++ b/scripts/build_transmission_projects.py @@ -18,11 +18,11 @@ Outputs ------- -- ``transmission_projects/new_lines.csv``: New project lines to be added to the network. This includes new lines and upgraded lines. -- ``transmission_projects/new_links.csv``: New project links to be added to the network. This includes new links and upgraded links. -- ``transmission_projects/adjust_lines.csv``: For lines which are upgraded, the decommissioning year of the existing line is adjusted to the build year of the upgraded line. -- ``transmission_projects/adjust_links.csv``: For links which are upgraded, the decommissioning year of the existing link is adjusted to the build year of the upgraded link. -- ``transmission_projects/new_buses.csv``: For some links, we have to add new buses (e.g. North Sea Wind Power Hub). +- ``transmission/electricity_projects/new_lines.csv``: New project lines to be added to the network. This includes new lines and upgraded lines. +- ``transmission/electricity_projects/new_links.csv``: New project links to be added to the network. This includes new links and upgraded links. +- ``transmission/electricity_projects/adjust_lines.csv``: For lines which are upgraded, the decommissioning year of the existing line is adjusted to the build year of the upgraded line. +- ``transmission/electricity_projects/adjust_links.csv``: For links which are upgraded, the decommissioning year of the existing link is adjusted to the build year of the upgraded link. +- ``transmission/electricity_projects/new_buses.csv``: For some links, we have to add new buses (e.g. North Sea Wind Power Hub). """ import logging diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index 53e9fd53d7..f29c9f45d6 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -20,7 +20,7 @@ Outputs ------- -Two GeoJSON files in WGS84 (EPSG:4326) format are written per carrier and cluster configuration: +GeoJSON files in WGS84 (EPSG:4326) format are written per cluster configuration: 1. ``all_edges`` Full Delaunay edge table (one row per Delaunay edge) with columns: @@ -28,8 +28,7 @@ - ``name``: Canonical undirected edge identifier ``"bus0 -> bus1"``. - ``bus0``: Canonically ordered first bus id (lexicographic order). - ``bus1``: Canonically ordered second bus id. - - ``length``: Great-circle edge length in km multiplied by - ``length_factor``. + - ``length``: Great-circle edge length in km. - ``gabriel_edge``: ``True`` if the edge satisfies the Gabriel empty circle criterion. - ``selected_edge``: ``True`` if the edge is part of the final @@ -37,8 +36,8 @@ - ``geometry``: LineString geometry in ``EPSG:4326``. 2. ``candidates`` - Selected candidate corridor table (subset of Delaunay edges) with - columns: + One selected candidate corridor table per configured ``min_degree`` + (subset of Delaunay edges) with columns: - ``name`` - ``bus0`` @@ -75,14 +74,47 @@ "geometry", "underwater_fraction", ] -BUS_SUFFIX = { - "carbon_dioxide": "co2 stored", - "hydrogen": "H2", -} -LINK_PREFIX = { - "carbon_dioxide": "CO2 pipeline", - "hydrogen": "H2 pipeline", -} + + +def collect_transmission_topology_settings( + transmission_cfg: dict, +) -> bool: + """ + Collect shared topology settings from carriers with gabriel_filter.min_degree. + + Returns + ------- + bool + Shared ``gabriel_filter_enabled`` value. + """ + relevant = [] + for _, carrier_cfg in transmission_cfg.items(): + if not isinstance(carrier_cfg, dict): + continue + if not carrier_cfg.get("enable", False): + continue + gabriel_cfg = carrier_cfg.get("gabriel_filter", {}) + if not isinstance(gabriel_cfg, dict): + continue + if not gabriel_cfg.get("enable", False): + continue + if "min_degree" not in gabriel_cfg: + continue + relevant.append((carrier_cfg, gabriel_cfg)) + + if not relevant: + return False + + gabriel_enabled_values = { + bool(gabriel_cfg.get("enable", True)) for _, gabriel_cfg in relevant + } + if len(gabriel_enabled_values) != 1: + raise ValueError( + "Inconsistent transmission..gabriel_filter.enable across " + "carriers with gabriel_filter.min_degree." + ) + + return gabriel_enabled_values.pop() def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: @@ -232,7 +264,6 @@ def delaunay_triangulation( bus_ids: pd.Index, coords_geo: NDArray[np.float64], coords_meter: NDArray[np.float64], - length_factor: float = 1.25, ) -> gpd.GeoDataFrame: """ Build the full Delaunay edge table with geometry and metadata. @@ -260,8 +291,7 @@ def delaunay_triangulation( v = edges_arr[:, 1] logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) - logger.info("Calculating edge lengths using length factor %.2f.", length_factor) - lengths = haversine_pts(coords_geo[u], coords_geo[v]) * length_factor + lengths = haversine_pts(coords_geo[u], coords_geo[v]) gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] @@ -448,30 +478,6 @@ def prepare_candidate_edges( return selected -def mark_selected_edges( - delaunay_graph: gpd.GeoDataFrame, - selected_edges: gpd.GeoDataFrame, -) -> gpd.GeoDataFrame: - """ - Annotate all Delaunay edges with selection membership. - - Parameters - ---------- - delaunay_graph : gpd.GeoDataFrame - Full Delaunay edge table to annotate. - selected_edges : gpd.GeoDataFrame - Selected candidate edges containing the ``name`` column. - - Returns - ------- - gpd.GeoDataFrame - ``delaunay_graph`` with an added boolean ``selected_edge`` column. - """ - selected_names = set(selected_edges["name"]) - delaunay_graph["selected_edge"] = delaunay_graph["name"].isin(selected_names) - return delaunay_graph - - def add_underwater_fraction( edges: gpd.GeoDataFrame, offshore_shapes: gpd.GeoDataFrame, @@ -507,7 +513,6 @@ def add_underwater_fraction( snakemake = mock_snakemake( "build_transmission_topology", - carrier="carbon_dioxide", clusters="200", run="nodes200", configfiles=["config/config.200.yaml"], @@ -516,16 +521,16 @@ def add_underwater_fraction( configure_logging(snakemake) set_scenario_config(snakemake) - carrier_enabled = bool(snakemake.params["carrier_enabled"]) - carrier = snakemake.wildcards.carrier - gabriel_filter_enabled = bool(snakemake.params["gabriel_filter_enabled"]) - min_degree = int(snakemake.params["min_degree"]) - length_factor = float(snakemake.params["length_factor"]) + transmission_cfg = dict(snakemake.params["transmission"]) + configured_min_degrees = sorted({int(v) for v in snakemake.params["min_degrees"]}) + candidate_file_by_degree = { + int(min_degree): path + for min_degree, path in snakemake.params[ + "candidate_outputs_by_min_degree" + ].items() + } - if not carrier_enabled: - raise ValueError( - "Transmission carrier is disabled in config for this wildcard." - ) + gabriel_filter_enabled = collect_transmission_topology_settings(transmission_cfg) n = pypsa.Network(snakemake.input.network) offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) @@ -536,30 +541,35 @@ def add_underwater_fraction( bus_ids=bus_ids, coords_geo=coords_geo, coords_meter=coords_meter, - length_factor=length_factor, ) add_underwater_fraction(delaunay_graph, offshore_shapes) - selected_edges = prepare_candidate_edges( - delaunay_graph=delaunay_graph, - gabriel_filter_enabled=gabriel_filter_enabled, - node_count=len(coords_geo), - min_degree=min_degree, - ) - selected_edges = selected_edges[COLS_EDGES] - delaunay_graph = mark_selected_edges(delaunay_graph, selected_edges) - - # Rename buses and link names - delaunay_graph[["bus0", "bus1"]] = ( - delaunay_graph[["bus0", "bus1"]] + " " + BUS_SUFFIX[carrier] - ) - delaunay_graph["name"] = LINK_PREFIX[carrier] + " " + delaunay_graph["name"] - selected_edges[["bus0", "bus1"]] = ( - selected_edges[["bus0", "bus1"]] + " " + BUS_SUFFIX[carrier] - ) - selected_edges["name"] = LINK_PREFIX[carrier] + " " + selected_edges["name"] + if configured_min_degrees: + selected_edges_base = prepare_candidate_edges( + delaunay_graph=delaunay_graph, + gabriel_filter_enabled=gabriel_filter_enabled, + node_count=len(coords_geo), + min_degree=min(configured_min_degrees), + ) + else: + selected_edges_base = delaunay_graph.copy() + selected_edges_base = selected_edges_base[COLS_EDGES] # Export delaunay_graph.to_file(snakemake.output.all_edges) - selected_edges.to_file(snakemake.output.candidates) + + for min_degree in configured_min_degrees: + if min_degree not in candidate_file_by_degree: + raise ValueError( + f"Missing output file for configured min_degree value: {min_degree}" + ) + + logger.info("Preparing candidate edges for min_degree=%s.", min_degree) + selected_edges = prepare_candidate_edges( + delaunay_graph=delaunay_graph, + gabriel_filter_enabled=gabriel_filter_enabled, + node_count=len(coords_geo), + min_degree=min_degree, + )[COLS_EDGES] + selected_edges.to_file(candidate_file_by_degree[min_degree]) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 1923c588e3..b75128e32c 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -873,7 +873,11 @@ def add_co2_tracking( def add_co2_network( - n, co2_transmission_candidates, costs, co2_transmission_cost_factor=1.0 + n, + carbon_dioxide_transmission_candidates, + costs, + co2_transmission_cost_factor=1.0, + co2_transmission_length_factor=1.0, ): """ Add CO2 transport network to the PyPSA network. @@ -886,7 +890,7 @@ def add_co2_network( ---------- n : pypsa.Network The PyPSA network container object - co2_transmission_candidates : str + carbon_dioxide_transmission_candidates : str Path to GeoJSON file containing CO2 pipeline candidates costs : pd.DataFrame Cost assumptions for different technologies. Must contain entries for @@ -894,6 +898,9 @@ def add_co2_network( columns co2_transmission_cost_factor : float, optional Factor to scale the capital costs of the CO2 network, default 1.0 + co2_transmission_length_factor : float, optional + Factor to scale transmission lengths read from topology candidates, + default 1.0 Returns ------- @@ -907,20 +914,31 @@ def add_co2_network( created using the create_network_topology helper function. """ logger.info("Adding CO2 network.") - co2_links = gpd.read_file(co2_transmission_candidates).set_index("name") + co2_links = gpd.read_file(carbon_dioxide_transmission_candidates).copy() + + if "name" not in co2_links.columns: + co2_links["name"] = co2_links["bus0"] + " -> " + co2_links["bus1"] + + if not co2_links.empty and not co2_links.iloc[0]["bus0"].endswith(" co2 stored"): + co2_links[["bus0", "bus1"]] = co2_links[["bus0", "bus1"]] + " co2 stored" + + co2_links = co2_links.set_index("name") + co2_links.index = "CO2 pipeline " + co2_links.index if "underwater_fraction" not in co2_links.columns: co2_links["underwater_fraction"] = 0.0 + scaled_length = co2_links["length"] * co2_transmission_length_factor + cost_onshore = ( (1 - co2_links.underwater_fraction) * costs.at["CO2 pipeline", "capital_cost"] - * co2_links["length"] + * scaled_length ) cost_submarine = ( co2_links.underwater_fraction * costs.at["CO2 submarine pipeline", "capital_cost"] - * co2_links["length"] + * scaled_length ) capital_cost = cost_onshore + cost_submarine capital_cost *= co2_transmission_cost_factor @@ -932,7 +950,7 @@ def add_co2_network( bus1=co2_links["bus1"].values, p_min_pu=-1, p_nom_extendable=True, - length=co2_links["length"].values, + length=scaled_length.values, capital_cost=capital_cost.values, carrier="CO2 pipeline", lifetime=costs.at["CO2 pipeline", "lifetime"], @@ -1757,7 +1775,7 @@ def add_h2_gas_infrastructure( costs, pop_layout, h2_cavern_file, - h2_transmission_candidates, + hydrogen_transmission_candidates, cavern_types, clustered_gas_network_file, gas_input_nodes, @@ -1778,7 +1796,7 @@ def add_h2_gas_infrastructure( Population layout with index of locations/nodes h2_cavern_file : str Path to CSV file containing hydrogen cavern storage potentials - h2_transmission_candidates : str + hydrogen_transmission_candidates : str Path to GeoJSON file containing hydrogen pipeline candidates cavern_types : list List of underground storage types to consider @@ -2081,7 +2099,16 @@ def add_h2_gas_infrastructure( if cf_transmission["hydrogen"]["enable"]: logger.info("Add options for new hydrogen pipelines.") - h2_pipes = gpd.read_file(h2_transmission_candidates).set_index("name") + h2_pipes = gpd.read_file(hydrogen_transmission_candidates).copy() + if "name" not in h2_pipes.columns: + h2_pipes["name"] = h2_pipes["bus0"] + " -> " + h2_pipes["bus1"] + + if not h2_pipes.empty and not h2_pipes.iloc[0]["bus0"].endswith(" H2"): + h2_pipes[["bus0", "bus1"]] = h2_pipes[["bus0", "bus1"]] + " H2" + + h2_pipes = h2_pipes.set_index("name") + h2_pipes.index = "H2 pipeline " + h2_pipes.index + h2_bus_ids = n.buses.index[n.buses.carrier == "H2"] h2_pipes = h2_pipes[ h2_pipes["bus0"].isin(h2_bus_ids) & h2_pipes["bus1"].isin(h2_bus_ids) @@ -2094,9 +2121,13 @@ def add_h2_gas_infrastructure( bus1=h2_pipes["bus1"].values, p_min_pu=-1, p_nom_extendable=True, - length=h2_pipes["length"].values, + length=( + h2_pipes["length"].values + * cf_transmission["hydrogen"].get("length_factor", 1.0) + ), capital_cost=costs.at["H2 (g) pipeline", "capital_cost"] * h2_pipes["length"].values + * cf_transmission["hydrogen"].get("length_factor", 1.0) * cf_transmission["hydrogen"]["cost_factor"], carrier="H2 pipeline", lifetime=costs.at["H2 (g) pipeline", "lifetime"], @@ -6358,7 +6389,11 @@ def add_import_options( costs=costs, pop_layout=pop_layout, h2_cavern_file=snakemake.input.h2_cavern, - h2_transmission_candidates=snakemake.input.h2_transmission_candidates, + hydrogen_transmission_candidates=( + snakemake.input.hydrogen_transmission_candidates + if hasattr(snakemake.input, "hydrogen_transmission_candidates") + else None + ), cavern_types=snakemake.params.sector["hydrogen_underground_storage_locations"], clustered_gas_network_file=snakemake.input.clustered_gas_network, gas_input_nodes=gas_input_nodes, @@ -6523,11 +6558,14 @@ def add_import_options( if cf_transmission["carbon_dioxide"]["enable"]: add_co2_network( n=n, - co2_transmission_candidates=snakemake.input.co2_transmission_candidates, + carbon_dioxide_transmission_candidates=snakemake.input.carbon_dioxide_transmission_candidates, costs=costs, co2_transmission_cost_factor=cf_transmission["carbon_dioxide"][ "cost_factor" ], + co2_transmission_length_factor=cf_transmission["carbon_dioxide"].get( + "length_factor", 1.0 + ), ) if options["allam_cycle_gas"]: @@ -6625,8 +6663,4 @@ def add_import_options( sanitize_carriers(n, snakemake.config) sanitize_locations(n) - # Filter carrier links and map to width 1, others zero - lwidth = (n.links["carrier"] == "CO2 pipeline").astype(int) * 5 - n.explore(branch_components=["Link"], link_width=lwidth) - n.export_to_netcdf(snakemake.output[0]) From 0712c9138f978561c87bdd8579fcebce201dfcdf Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Thu, 23 Apr 2026 16:04:05 +0200 Subject: [PATCH 17/32] Added prefix electricity_ to rules/scripts/docs of add_transmission_projects_and_dlr and build_transmission projects for clarity. --- doc/preparation.rst | 8 +-- doc/tutorial.rst | 64 +++++++++---------- doc/tutorial_sector.rst | 12 ++-- rules/build_electricity.smk | 18 +++--- ...ctricity_transmission_projects_and_dlr.py} | 2 +- ...uild_electricity_transmission_projects.py} | 2 +- 6 files changed, 53 insertions(+), 53 deletions(-) rename scripts/{add_transmission_projects_and_dlr.py => add_electricity_transmission_projects_and_dlr.py} (97%) rename scripts/{build_transmission_projects.py => build_electricity_transmission_projects.py} (99%) diff --git a/doc/preparation.rst b/doc/preparation.rst index a6cce4d5bb..7a29fab66b 100644 --- a/doc/preparation.rst +++ b/doc/preparation.rst @@ -79,20 +79,20 @@ Rule ``build_natura`` .. automodule:: build_natura -Rule ``build_transmission_projects`` +Rule ``build_electricity_transmission_projects`` ==================================== -.. automodule:: build_transmission_projects +.. automodule:: build_electricity_transmission_projects Rule ``build_line_rating`` ================================= .. automodule:: build_line_rating -Rule ``add_transmission_projects_and_dlr`` +Rule ``add_electricity_transmission_projects_and_dlr`` =========================================== -.. automodule:: add_transmission_projects_and_dlr +.. automodule:: add_electricity_transmission_projects_and_dlr Rule ``build_bidding_zones`` ============================= diff --git a/doc/tutorial.rst b/doc/tutorial.rst index a80a63f202..2a6f18c271 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -172,12 +172,12 @@ This triggers a workflow of multiple preceding jobs that depend on each rule's i 17[label = "retrieve_jrc_ardeco", color = "0.06 0.6 0.85", style="rounded"]; 18[label = "cluster_network\nclusters: 6", color = "0.12 0.6 0.85", style="rounded"]; 19[label = "simplify_network", color = "0.33 0.6 0.85", style="rounded"]; - 20[label = "add_transmission_projects_and_dlr", color = "0.46 0.6 0.85", style="rounded"]; + 20[label = "add_electricity_transmission_projects_and_dlr", color = "0.46 0.6 0.85", style="rounded"]; 21[label = "base_network", color = "0.00 0.6 0.85", style="rounded"]; 22[label = "retrieve_osm_prebuilt", color = "0.41 0.6 0.85", style="rounded"]; 23[label = "build_line_rating", color = "0.57 0.6 0.85", style="rounded"]; 24[label = "retrieve_cutout\ncutout: be-03-2013-era5", color = "0.18 0.6 0.85", style="rounded"]; - 25[label = "build_transmission_projects", color = "0.04 0.6 0.85", style="rounded"]; + 25[label = "build_electricity_transmission_projects", color = "0.04 0.6 0.85", style="rounded"]; 26[label = "build_electricity_demand_base", color = "0.61 0.6 0.85", style="rounded"]; 27[label = "build_electricity_demand", color = "0.16 0.6 0.85", style="rounded"]; 28[label = "retrieve_electricity_demand", color = "0.21 0.6 0.85", style="rounded"]; @@ -307,36 +307,36 @@ In the terminal, this will show up as a list of jobs to be run: Building DAG of jobs... Job stats: - job count - ------------------------------------- ------- - add_electricity 1 - add_transmission_projects_and_dlr 1 - base_network 1 - build_electricity_demand 1 - build_electricity_demand_base 1 - build_line_rating 1 - build_osm_boundaries 4 - build_powerplants 1 - build_renewable_profiles 6 - build_shapes 1 - build_ship_raster 1 - build_transmission_projects 1 - cluster_network 1 - determine_availability_matrix 6 - prepare_network 1 - retrieve_cost_data 1 - retrieve_databundle 1 - retrieve_eez 1 - retrieve_electricity_demand 1 - retrieve_jrc_ardeco 1 - retrieve_nuts_2021_shapes 1 - retrieve_osm_boundaries 4 - retrieve_osm_prebuilt 1 - retrieve_ship_raster 1 - retrieve_synthetic_electricity_demand 1 - simplify_network 1 - solve_network 1 - total 43 + job count + ------------------------------------- ------- + add_electricity 1 + add_electricity_transmission_projects_and_dlr 1 + base_network 1 + build_electricity_demand 1 + build_electricity_demand_base 1 + build_line_rating 1 + build_osm_boundaries 4 + build_powerplants 1 + build_renewable_profiles 6 + build_shapes 1 + build_ship_raster 1 + build_electricity_transmission_projects 1 + cluster_network 1 + determine_availability_matrix 6 + prepare_network 1 + retrieve_cost_data 1 + retrieve_databundle 1 + retrieve_eez 1 + retrieve_electricity_demand 1 + retrieve_jrc_ardeco 1 + retrieve_nuts_2021_shapes 1 + retrieve_osm_boundaries 4 + retrieve_osm_prebuilt 1 + retrieve_ship_raster 1 + retrieve_synthetic_electricity_demand 1 + simplify_network 1 + solve_network 1 + total 43 ``snakemake`` then runs these jobs in the correct order. diff --git a/doc/tutorial_sector.rst b/doc/tutorial_sector.rst index 7044198716..a1b923d750 100644 --- a/doc/tutorial_sector.rst +++ b/doc/tutorial_sector.rst @@ -68,7 +68,7 @@ which were already included in the electricity-only tutorial: job count ------------------------------------------------ ------- add_electricity 1 - add_transmission_projects_and_dlr 1 + add_electricity_transmission_projects_and_dlr 1 all 1 base_network 1 build_ammonia_production 1 @@ -105,7 +105,7 @@ which were already included in the electricity-only tutorial: build_ship_raster 1 build_shipping_demand 1 build_temperature_profiles 1 - build_transmission_projects 1 + build_electricity_transmission_projects 1 build_transport_demand 1 cluster_gas_network 1 cluster_network 1 @@ -186,10 +186,10 @@ successfully. 22[label = "retrieve_jrc_ardeco", color = "0.22 0.6 0.85", style="rounded"]; 23[label = "cluster_network\nclusters: 5", color = "0.39 0.6 0.85", style="rounded"]; 24[label = "simplify_network", color = "0.13 0.6 0.85", style="rounded"]; - 25[label = "add_transmission_projects_and_dlr", color = "0.44 0.6 0.85", style="rounded"]; + 25[label = "add_electricity_transmission_projects_and_dlr", color = "0.44 0.6 0.85", style="rounded"]; 26[label = "base_network", color = "0.62 0.6 0.85", style="rounded"]; 27[label = "retrieve_osm_prebuilt", color = "0.52 0.6 0.85", style="rounded"]; - 28[label = "build_transmission_projects", color = "0.32 0.6 0.85", style="rounded"]; + 28[label = "build_electricity_transmission_projects", color = "0.32 0.6 0.85", style="rounded"]; 29[label = "build_electricity_demand_base", color = "0.47 0.6 0.85", style="rounded"]; 30[label = "build_electricity_demand", color = "0.34 0.6 0.85", style="rounded"]; 31[label = "retrieve_electricity_demand", color = "0.61 0.6 0.85", style="rounded"]; @@ -560,10 +560,10 @@ workflow: 23[label = "retrieve_jrc_ardeco", color = "0.29 0.6 0.85", style="rounded"]; 24[label = "cluster_network\nclusters: 5", color = "0.30 0.6 0.85", style="rounded"]; 25[label = "simplify_network", color = "0.02 0.6 0.85", style="rounded"]; - 26[label = "add_transmission_projects_and_dlr", color = "0.60 0.6 0.85", style="rounded"]; + 26[label = "add_electricity_transmission_projects_and_dlr", color = "0.60 0.6 0.85", style="rounded"]; 27[label = "base_network", color = "0.30 0.6 0.85", style="rounded"]; 28[label = "retrieve_osm_prebuilt", color = "0.43 0.6 0.85", style="rounded"]; - 29[label = "build_transmission_projects", color = "0.51 0.6 0.85", style="rounded"]; + 29[label = "build_electricity_transmission_projects", color = "0.51 0.6 0.85", style="rounded"]; 30[label = "build_electricity_demand_base", color = "0.64 0.6 0.85", style="rounded"]; 31[label = "build_electricity_demand", color = "0.16 0.6 0.85", style="rounded"]; 32[label = "retrieve_electricity_demand", color = "0.33 0.6 0.85", style="rounded"]; diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index 39ffc16747..b207740514 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -500,7 +500,7 @@ rule build_line_rating: scripts("build_line_rating.py") -rule build_transmission_projects: +rule build_electricity_transmission_projects: input: base_network=resources("networks/base.nc"), offshore_shapes=resources("offshore_shapes.geojson"), @@ -519,9 +519,9 @@ rule build_transmission_projects: adjust_links=resources("transmission/electricity_projects/adjust_links.csv"), new_buses=resources("transmission/electricity_projects/new_buses.csv"), log: - logs("build_transmission_projects.log"), + logs("build_electricity_transmission_projects.log"), benchmark: - benchmarks("build_transmission_projects") + benchmarks("build_electricity_transmission_projects") threads: 1 resources: mem_mb=4000, @@ -532,12 +532,12 @@ rule build_transmission_projects: ), s_max_pu=config_provider("transmission", "electricity", "lines", "s_max_pu"), message: - "Building transmission projects" + "Building electricity transmission projects" script: - scripts("build_transmission_projects.py") + scripts("build_electricity_transmission_projects.py") -rule add_transmission_projects_and_dlr: +rule add_electricity_transmission_projects_and_dlr: input: network=resources("networks/base.nc"), dlr=lambda w: ( @@ -565,9 +565,9 @@ rule add_transmission_projects_and_dlr: output: network=resources("networks/base_extended.nc"), log: - logs("add_transmission_projects_and_dlr.log"), + logs("add_electricity_transmission_projects_and_dlr.log"), benchmark: - benchmarks("add_transmission_projects_and_dlr") + benchmarks("add_electricity_transmission_projects_and_dlr") threads: 1 resources: mem_mb=4000, @@ -580,7 +580,7 @@ rule add_transmission_projects_and_dlr: message: "Adding transmission projects and dynamic line ratings" script: - scripts("add_transmission_projects_and_dlr.py") + scripts("add_electricity_transmission_projects_and_dlr.py") def input_class_regions(w): diff --git a/scripts/add_transmission_projects_and_dlr.py b/scripts/add_electricity_transmission_projects_and_dlr.py similarity index 97% rename from scripts/add_transmission_projects_and_dlr.py rename to scripts/add_electricity_transmission_projects_and_dlr.py index 234dacf69d..36a1b0ac51 100644 --- a/scripts/add_transmission_projects_and_dlr.py +++ b/scripts/add_electricity_transmission_projects_and_dlr.py @@ -73,7 +73,7 @@ def attach_line_rating( if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake("add_transmission_projects_and_dlr") + snakemake = mock_snakemake("add_electricity_transmission_projects_and_dlr") configure_logging(snakemake) # pylint: disable=E0606 set_scenario_config(snakemake) diff --git a/scripts/build_transmission_projects.py b/scripts/build_electricity_transmission_projects.py similarity index 99% rename from scripts/build_transmission_projects.py rename to scripts/build_electricity_transmission_projects.py index c8b6d7595a..35f7550949 100644 --- a/scripts/build_transmission_projects.py +++ b/scripts/build_electricity_transmission_projects.py @@ -467,7 +467,7 @@ def fill_length_from_geometry(line, line_factor=1.2): if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake("build_transmission_projects") + snakemake = mock_snakemake("build_electricity_transmission_projects") configure_logging(snakemake) set_scenario_config(snakemake) From 2b3ce21d3e9825c4773af50334591f9b435a7d6d Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Thu, 23 Apr 2026 17:20:54 +0200 Subject: [PATCH 18/32] Snakemake 'run' wildcard fix. --- rules/build_sector.smk | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 34ac2496f4..a5d60becbb 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -171,10 +171,12 @@ rule build_transmission_topology: offshore_shapes=resources("offshore_shapes.geojson"), output: all_edges=resources("transmission/all_edges_{clusters}.geojson"), - candidates=expand( - resources("transmission/candidates_{{clusters}}_min_{min_degree}.geojson"), - min_degree=transmission_candidate_min_degrees(), - ), + candidates=[ + resources( + f"transmission/candidates_{{clusters}}_min_{int(min_degree)}.geojson" + ) + for min_degree in transmission_candidate_min_degrees() + ], log: logs("build_transmission_topology_{clusters}.log"), benchmark: From 21925e8176dd6ff7369d53eddee266323347d2d4 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Thu, 23 Apr 2026 18:31:06 +0200 Subject: [PATCH 19/32] Added max_offshore_haversine_distance key per transmission carrier (in km). --- config/config.default.yaml | 2 + rules/build_sector.smk | 39 +++++++++------ scripts/build_transmission_topology.py | 68 +++++++++++++++----------- 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index f89e9b7ec1..db7b4bfc35 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -247,6 +247,7 @@ transmission: min_degree: 1 length_factor: 1.25 cost_factor: 1 + max_offshore_haversine_distance: .inf carbon_dioxide: enable: true gabriel_filter: @@ -254,6 +255,7 @@ transmission: min_degree: 1 length_factor: 1.25 cost_factor: 1 + max_offshore_haversine_distance: .inf gas: enable: true diff --git a/rules/build_sector.smk b/rules/build_sector.smk index a5d60becbb..bdf6dc80bc 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: MIT +import math + rule build_population_layouts: input: @@ -146,8 +148,8 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") -def transmission_candidate_min_degrees() -> list[int]: - min_degrees = set() +def transmission_candidate_specs() -> list[tuple[int, str]]: + specs = set() for carrier_config in config.get("transmission", {}).values(): if not isinstance(carrier_config, dict): @@ -159,10 +161,14 @@ def transmission_candidate_min_degrees() -> list[int]: continue if not gabriel_filter.get("enable", False): continue - if isinstance(gabriel_filter, dict) and "min_degree" in gabriel_filter: - min_degrees.add(int(gabriel_filter["min_degree"])) + if "min_degree" not in gabriel_filter: + continue + + min_degree = int(gabriel_filter["min_degree"]) + max_offdist = f"{float(carrier_config.get('max_offshore_haversine_distance', float('inf'))):g}" + specs.add((min_degree, max_offdist)) - return sorted(min_degrees) + return sorted(specs, key=lambda spec: (spec[0], float(spec[1]))) rule build_transmission_topology: @@ -173,9 +179,10 @@ rule build_transmission_topology: all_edges=resources("transmission/all_edges_{clusters}.geojson"), candidates=[ resources( - f"transmission/candidates_{{clusters}}_min_{int(min_degree)}.geojson" + "transmission/candidates_{clusters}_min_" + + f"{int(min_degree)}_maxoffdist_{max_offdist}_km.geojson" ) - for min_degree in transmission_candidate_min_degrees() + for min_degree, max_offdist in transmission_candidate_specs() ], log: logs("build_transmission_topology_{clusters}.log"), @@ -185,13 +192,13 @@ rule build_transmission_topology: mem_mb=4000, params: transmission=config_provider("transmission"), - min_degrees=transmission_candidate_min_degrees(), - candidate_outputs_by_min_degree=lambda w: { - int(min_degree): resources( - f"transmission/candidates_{w.clusters}_min_{int(min_degree)}.geojson" - ) - for min_degree in transmission_candidate_min_degrees() - }, + candidate_specs=[ + { + "min_degree": int(min_degree), + "max_offshore_haversine_distance_km": float(max_offdist), + } + for min_degree, max_offdist in transmission_candidate_specs() + ], message: "Building transmission candidates for {wildcards.clusters} clusters" script: @@ -1625,8 +1632,10 @@ def input_transmission_candidates(w): 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_{gabriel_cfg['min_degree']}.geojson" + f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson" ) else: candidates[key] = resources("transmission/all_edges_{clusters}.geojson") diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index f29c9f45d6..dd82441106 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -443,6 +443,7 @@ def prepare_candidate_edges( gabriel_filter_enabled: bool, node_count: int, min_degree: int, + max_offshore_haversine_distance_km: float = float("inf"), ) -> gpd.GeoDataFrame: """ Build selected candidate edges from a Delaunay edge table. @@ -457,6 +458,9 @@ def prepare_candidate_edges( Number of nodes used for degree accounting. min_degree : int Minimum per-node degree target used for optional backfilling. + max_offshore_haversine_distance_km : float, default inf + Maximum allowed offshore edge length in km, computed as + ``underwater_fraction * length``. ``inf`` disables this filter. Returns ------- @@ -464,13 +468,23 @@ def prepare_candidate_edges( Selected edge table after optional Gabriel filtering and min-degree backfilling. """ + if np.isfinite(max_offshore_haversine_distance_km): + offshore_length = ( + delaunay_graph["length"] * delaunay_graph["underwater_fraction"] + ) + candidate_pool = delaunay_graph[ + offshore_length <= max_offshore_haversine_distance_km + ].copy() + else: + candidate_pool = delaunay_graph.copy() + if not gabriel_filter_enabled: - return delaunay_graph.copy() + return candidate_pool - selected_edges = delaunay_graph[delaunay_graph["gabriel_edge"]].copy() + selected_edges = candidate_pool[candidate_pool["gabriel_edge"]].copy() selected = enforce_min_degree( selected_edges=selected_edges, - delaunay_graph=delaunay_graph, + delaunay_graph=candidate_pool, node_count=node_count, min_degree=min_degree, ) @@ -522,15 +536,17 @@ def add_underwater_fraction( set_scenario_config(snakemake) transmission_cfg = dict(snakemake.params["transmission"]) - configured_min_degrees = sorted({int(v) for v in snakemake.params["min_degrees"]}) - candidate_file_by_degree = { - int(min_degree): path - for min_degree, path in snakemake.params[ - "candidate_outputs_by_min_degree" - ].items() - } + candidate_specs = [ + { + "min_degree": int(spec["min_degree"]), + "max_offshore_haversine_distance_km": float( + spec["max_offshore_haversine_distance_km"] + ), + } + for spec in snakemake.params["candidate_specs"] + ] - gabriel_filter_enabled = collect_transmission_topology_settings(transmission_cfg) + gabriel_filter_enabled = bool(candidate_specs) n = pypsa.Network(snakemake.input.network) offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) @@ -545,31 +561,25 @@ def add_underwater_fraction( add_underwater_fraction(delaunay_graph, offshore_shapes) - if configured_min_degrees: - selected_edges_base = prepare_candidate_edges( - delaunay_graph=delaunay_graph, - gabriel_filter_enabled=gabriel_filter_enabled, - node_count=len(coords_geo), - min_degree=min(configured_min_degrees), - ) - else: - selected_edges_base = delaunay_graph.copy() - selected_edges_base = selected_edges_base[COLS_EDGES] - # Export delaunay_graph.to_file(snakemake.output.all_edges) - for min_degree in configured_min_degrees: - if min_degree not in candidate_file_by_degree: - raise ValueError( - f"Missing output file for configured min_degree value: {min_degree}" - ) + for spec, output_path in zip( + candidate_specs, snakemake.output.candidates, strict=True + ): + min_degree = spec["min_degree"] + max_offshore_haversine_distance_km = spec["max_offshore_haversine_distance_km"] - logger.info("Preparing candidate edges for min_degree=%s.", min_degree) + logger.info( + "Preparing candidate edges for min_degree=%s and max_offshore_haversine_distance_km=%s.", + min_degree, + max_offshore_haversine_distance_km, + ) selected_edges = prepare_candidate_edges( delaunay_graph=delaunay_graph, gabriel_filter_enabled=gabriel_filter_enabled, node_count=len(coords_geo), min_degree=min_degree, + max_offshore_haversine_distance_km=max_offshore_haversine_distance_km, )[COLS_EDGES] - selected_edges.to_file(candidate_file_by_degree[min_degree]) + selected_edges.to_file(output_path) From 06198f681bf88db0a417268424080731751bcfe0 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Thu, 23 Apr 2026 18:36:05 +0200 Subject: [PATCH 20/32] Added to schema/validation. --- config/config.default.yaml | 4 +-- config/schema.default.json | 30 +++++++++++++++++++ scripts/lib/validation/config/transmission.py | 5 ++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index db7b4bfc35..67532c3373 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -245,17 +245,17 @@ transmission: gabriel_filter: enable: true min_degree: 1 + max_offshore_haversine_distance: .inf length_factor: 1.25 cost_factor: 1 - max_offshore_haversine_distance: .inf carbon_dioxide: enable: true gabriel_filter: enable: true min_degree: 1 + max_offshore_haversine_distance: .inf length_factor: 1.25 cost_factor: 1 - max_offshore_haversine_distance: .inf gas: enable: true diff --git a/config/schema.default.json b/config/schema.default.json index 2e0fe5a65a..eca41d9e9c 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5909,6 +5909,12 @@ } } }, + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" + }, "length_factor": { "default": 1.25, "description": "Multiplier applied to geometric corridor lengths.", @@ -5947,6 +5953,12 @@ } } }, + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" + }, "length_factor": { "default": 1.25, "description": "Multiplier applied to geometric corridor lengths.", @@ -8675,6 +8687,12 @@ } } }, + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" + }, "length_factor": { "default": 1.25, "description": "Multiplier applied to geometric corridor lengths.", @@ -9657,6 +9675,12 @@ } } }, + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" + }, "length_factor": { "default": 1.25, "description": "Multiplier applied to geometric corridor lengths.", @@ -9695,6 +9719,12 @@ } } }, + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" + }, "length_factor": { "default": 1.25, "description": "Multiplier applied to geometric corridor lengths.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index f35b8ff67d..3e25ce7960 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -204,6 +204,11 @@ class _TransmissionCarrierConfigGeneral(BaseModel): default_factory=_GabrielFilterConfig, description="Gabriel filter configuration.", ) + 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).", + ) length_factor: float = Field( 1.25, gt=0, From c123c929011ee10e4f45d373b7866846309c9967 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 14:13:31 +0200 Subject: [PATCH 21/32] Moved distribution grid into new transmission key 'electricity_distribution'. --- config/config.default.yaml | 7 +- config/schema.default.json | 68 +++++++++++++------ scripts/_helpers.py | 4 +- scripts/add_existing_baseyear.py | 4 +- scripts/lib/validation/config/sector.py | 10 +-- scripts/lib/validation/config/transmission.py | 15 ++++ scripts/prepare_sector_network.py | 8 +-- 7 files changed, 75 insertions(+), 41 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 67532c3373..6e4e27a07c 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -240,6 +240,9 @@ transmission: - in_permitting - confirmed new_link_capacity: zero + electricity_distribution: + enable: true + cost_factor: 1.0 hydrogen: enable: true gabriel_filter: @@ -913,9 +916,7 @@ sector: use_methanation_waste_heat: 0.25 use_fuel_cell_waste_heat: 1 use_electrolysis_waste_heat: 0.25 - electricity_distribution_grid: true - electricity_distribution_grid_cost_factor: 1.0 - electricity_grid_connection: true + electricity_grid_connection_costs: true transmission_efficiency: enable: - DC diff --git a/config/schema.default.json b/config/schema.default.json index eca41d9e9c..f5d787aa5a 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4870,17 +4870,7 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_distribution_grid": { - "default": true, - "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - "type": "boolean" - }, - "electricity_distribution_grid_cost_factor": { - "default": 1.0, - "description": "Multiplies the investment cost of the electricity distribution grid.", - "type": "number" - }, - "electricity_grid_connection": { + "electricity_grid_connection_costs": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" @@ -5885,6 +5875,21 @@ } } }, + "electricity_distribution": { + "properties": { + "enable": { + "default": true, + "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "type": "boolean" + }, + "cost_factor": { + "default": 1.0, + "description": "Multiplies the investment cost of the electricity distribution grid.", + "type": "number" + } + }, + "description": "Configuration for simplified electricity distribution grid." + }, "hydrogen": { "description": "Configuration for a single transmission carrier.", "properties": { @@ -8653,6 +8658,20 @@ } } }, + "_TransmissionCarrierConfigElectricityDistribution": { + "properties": { + "enable": { + "default": true, + "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "type": "boolean" + }, + "cost_factor": { + "default": 1.0, + "description": "Multiplies the investment cost of the electricity distribution grid.", + "type": "number" + } + } + }, "_TransmissionCarrierConfigGas": { "properties": { "enable": { @@ -9651,6 +9670,21 @@ } } }, + "electricity_distribution": { + "properties": { + "enable": { + "default": true, + "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "type": "boolean" + }, + "cost_factor": { + "default": 1.0, + "description": "Multiplies the investment cost of the electricity distribution grid.", + "type": "number" + } + }, + "description": "Configuration for simplified electricity distribution grid." + }, "hydrogen": { "description": "Configuration for a single transmission carrier.", "properties": { @@ -12018,17 +12052,7 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_distribution_grid": { - "default": true, - "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - "type": "boolean" - }, - "electricity_distribution_grid_cost_factor": { - "default": 1.0, - "description": "Multiplies the investment cost of the electricity distribution grid.", - "type": "number" - }, - "electricity_grid_connection": { + "electricity_grid_connection_costs": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" diff --git a/scripts/_helpers.py b/scripts/_helpers.py index 2f08afccb8..16ebaded04 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -810,9 +810,9 @@ def update_config_from_wildcards(config, w, inplace=True): dg_enable, dg_factor = find_opt(opts, "dist") if dg_enable: - config["sector"]["electricity_distribution_grid"] = True + config["transmission"]["electricity_distribution"]["enable"] = True if dg_factor is not None: - config["sector"]["electricity_distribution_grid_cost_factor"] = ( + config["transmission"]["electricity_distribution"]["cost_factor"] = ( dg_factor ) diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index d42e37e635..bf8d3d27b1 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -834,7 +834,9 @@ def add_heating_capacities_installed_before_baseyear( capacity_threshold=snakemake.params.existing_capacities[ "threshold_capacity" ], - use_electricity_distribution_grid=options["electricity_distribution_grid"], + use_electricity_distribution_grid=cf_transmission[ + "electricity_distribution" + ]["enable"], ) # Set defaults for missing missing values diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index a0329cbbce..fb698e7398 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -799,15 +799,7 @@ class SectorConfig(BaseModel): description="Add option for using waste heat of electrolysis in district heating networks.", ) - electricity_distribution_grid: bool = Field( - True, - description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - ) - electricity_distribution_grid_cost_factor: float = Field( - 1.0, - description="Multiplies the investment cost of the electricity distribution grid.", - ) - electricity_grid_connection: bool = Field( + electricity_grid_connection_costs: bool = Field( True, description="Add the cost of electricity grid connection for onshore wind and solar.", ) diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index 3e25ce7960..3416971009 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -261,6 +261,17 @@ class _TransmissionCarrierConfigGas(BaseModel): ) +class _TransmissionCarrierConfigElectricityDistribution(BaseModel): + enable: bool = Field( + True, + description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + ) + cost_factor: float = Field( + 1.0, + description="Multiplies the investment cost of the electricity distribution grid.", + ) + + class TransmissionConfig(BaseModel): """Configuration for `transmission` settings.""" @@ -268,6 +279,10 @@ class TransmissionConfig(BaseModel): default_factory=_TransmissionCarrierConfigElectricity, description="Configuration for electricity transmission grid.", ) + electricity_distribution: _TransmissionCarrierConfigElectricityDistribution = Field( + default_factory=_TransmissionCarrierConfigElectricityDistribution, + description="Configuration for simplified electricity distribution grid.", + ) hydrogen: _TransmissionCarrierConfigGeneral = Field( default_factory=_TransmissionCarrierConfigGeneral, description="Configuration for hydrogen transmission candidates.", diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b75128e32c..f265136ddb 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1760,7 +1760,7 @@ def insert_gas_distribution_costs( n.links.loc[mchp, "capital_cost"] += capital_cost -def add_electricity_grid_connection(n, costs): +def add_electricity_grid_connection_costs(n, costs): carriers = ["onwind", "solar", "solar-hsat"] gens = n.generators.index[n.generators.carrier.isin(carriers)] @@ -6608,7 +6608,7 @@ def add_import_options( if maxext is not None: limit_individual_line_extension(n, maxext) - if options["electricity_distribution_grid"]: + if cf_transmission["electricity_distribution"]["enable"]: insert_electricity_distribution_grid( n, costs, options, pop_layout, snakemake.input.solar_rooftop_potentials ) @@ -6631,8 +6631,8 @@ def add_import_options( if options["gas_distribution_grid"]: insert_gas_distribution_costs(n, costs, options=options) - if options["electricity_grid_connection"]: - add_electricity_grid_connection(n, costs) + if options["electricity_grid_connection_costs"]: + add_electricity_grid_connection_costs(n, costs) for k, v in options["transmission_efficiency"].items(): if k in options["transmission_efficiency"]["enable"]: From 2f3cbf861354ba10f5b44567d329d67c0cc7f38a Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 14:50:15 +0200 Subject: [PATCH 22/32] Moved carrier-specific transmission efficiencies into the new subsections [][efficiency] respectively. --- config/config.default.yaml | 34 +- config/schema.default.json | 469 +++++++++++++----- config/test/config.tyndp.yaml | 7 - scripts/lib/validation/config/sector.py | 53 +- scripts/lib/validation/config/transmission.py | 76 ++- scripts/prepare_sector_network.py | 48 +- 6 files changed, 466 insertions(+), 221 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 6e4e27a07c..e6c705404f 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -222,6 +222,10 @@ transmission: 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 @@ -243,6 +247,9 @@ transmission: electricity_distribution: enable: true cost_factor: 1.0 + efficiency: + enable: true + efficiency_static: 0.97 hydrogen: enable: true gabriel_filter: @@ -251,6 +258,10 @@ transmission: max_offshore_haversine_distance: .inf length_factor: 1.25 cost_factor: 1 + efficiency: + enable: true + efficiency_per_1000km: 1.0 + compression_per_1000km: 0.018 carbon_dioxide: enable: true gabriel_filter: @@ -261,6 +272,10 @@ transmission: cost_factor: 1 gas: enable: true + efficiency: + enable: true + efficiency_per_1000km: 1.0 + compression_per_1000km: 0.01 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite atlite: @@ -916,24 +931,7 @@ sector: use_methanation_waste_heat: 0.25 use_fuel_cell_waste_heat: 1 use_electrolysis_waste_heat: 0.25 - electricity_grid_connection_costs: true - transmission_efficiency: - enable: - - DC - - H2 pipeline - - gas pipeline - - electricity distribution grid - DC: - efficiency_static: 0.98 - efficiency_per_1000km: 0.977 - H2 pipeline: - efficiency_per_1000km: 1 - compression_per_1000km: 0.018 - gas pipeline: - efficiency_per_1000km: 1 - compression_per_1000km: 0.01 - electricity distribution grid: - efficiency_static: 0.97 + electricity_grid_connection_cost: true H2_retrofit: false H2_retrofit_capacity_per_CH4: 0.6 gas_network_connectivity_upgrade: 1 diff --git a/config/schema.default.json b/config/schema.default.json index f5d787aa5a..bf73d3b35f 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -2818,6 +2818,26 @@ "keep" ], "type": "string" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity.links.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for DC links.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.98, + "description": "Static DC transmission efficiency.", + "type": "number" + }, + "efficiency_per_1000km": { + "default": 0.977, + "description": "Distance-dependent DC efficiency factor per 1000 km.", + "type": "number" + } + } } } }, @@ -4870,51 +4890,11 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_grid_connection_costs": { + "electricity_grid_connection_cost": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "transmission_efficiency": { - "description": "Configuration for `sector.transmission_efficiency` settings.", - "properties": { - "enable": { - "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", - "items": { - "type": "string" - }, - "type": "array" - }, - "DC": { - "additionalProperties": { - "type": "number" - }, - "description": "DC transmission efficiency.", - "type": "object" - }, - "H2 pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "H2 pipeline transmission efficiency.", - "type": "object" - }, - "gas pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "Gas pipeline transmission efficiency.", - "type": "object" - }, - "electricity distribution grid": { - "additionalProperties": { - "type": "number" - }, - "description": "Electricity distribution grid efficiency.", - "type": "object" - } - } - }, "H2_retrofit": { "default": false, "description": "Add option for retrofiting existing pipelines to transport hydrogen.", @@ -5784,6 +5764,26 @@ "keep" ], "type": "string" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity.links.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for DC links.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.98, + "description": "Static DC transmission efficiency.", + "type": "number" + }, + "efficiency_per_1000km": { + "default": 0.977, + "description": "Distance-dependent DC efficiency factor per 1000 km.", + "type": "number" + } + } } } }, @@ -5886,12 +5886,26 @@ "default": 1.0, "description": "Multiplies the investment cost of the electricity distribution grid.", "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply electricity distribution efficiency.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.97, + "description": "Static electricity distribution efficiency.", + "type": "number" + } + } } }, "description": "Configuration for simplified electricity distribution grid." }, "hydrogen": { - "description": "Configuration for a single transmission carrier.", "properties": { "enable": { "default": true, @@ -5931,8 +5945,29 @@ "description": "Multiplier applied to the capital cost of transmission infrastructure.", "exclusiveMinimum": 0, "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } } - } + }, + "description": "Configuration for hydrogen transmission candidates." }, "carbon_dioxide": { "description": "Configuration for a single transmission carrier.", @@ -5982,9 +6017,29 @@ "properties": { "enable": { "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } } }, "description": "Configuration for methane gas transmission candidates." @@ -6529,6 +6584,26 @@ } } }, + "_DCEfficiencyConfig": { + "description": "Configuration for `transmission.electricity.links.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for DC links.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.98, + "description": "Static DC transmission efficiency.", + "type": "number" + }, + "efficiency_per_1000km": { + "default": 0.977, + "description": "Distance-dependent DC efficiency factor per 1000 km.", + "type": "number" + } + } + }, "_DataSourceConfig": { "description": "Configuration for a single data source.", "properties": { @@ -6702,6 +6777,21 @@ } } }, + "_ElectricityDistributionEfficiencyConfig": { + "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply electricity distribution efficiency.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.97, + "description": "Static electricity distribution efficiency.", + "type": "number" + } + } + }, "_EmissionPricesConfig": { "description": "Configuration for `costs.emission_prices` settings.", "properties": { @@ -8567,6 +8657,26 @@ "keep" ], "type": "string" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity.links.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for DC links.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.98, + "description": "Static DC transmission efficiency.", + "type": "number" + }, + "efficiency_per_1000km": { + "default": 0.977, + "description": "Distance-dependent DC efficiency factor per 1000 km.", + "type": "number" + } + } } } }, @@ -8669,6 +8779,21 @@ "default": 1.0, "description": "Multiplies the investment cost of the electricity distribution grid.", "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply electricity distribution efficiency.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.97, + "description": "Static electricity distribution efficiency.", + "type": "number" + } + } } } }, @@ -8676,9 +8801,29 @@ "properties": { "enable": { "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } } } }, @@ -8726,43 +8871,86 @@ } } }, - "_TransmissionEfficiencyConfig": { - "description": "Configuration for `sector.transmission_efficiency` settings.", + "_TransmissionCarrierConfigHydrogen": { "properties": { "enable": { - "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", - "items": { - "type": "string" - }, - "type": "array" + "default": true, + "description": "Enable transmission candidate generation for this carrier.", + "type": "boolean" }, - "DC": { - "additionalProperties": { - "type": "number" - }, - "description": "DC transmission efficiency.", - "type": "object" + "gabriel_filter": { + "description": "Configuration for `transmission..gabriel_filter` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", + "type": "boolean" + }, + "min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel filtering.", + "minimum": 0, + "type": "integer" + } + } }, - "H2 pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "H2 pipeline transmission efficiency.", - "type": "object" + "max_offshore_haversine_distance": { + "default": null, + "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).", + "exclusiveMinimum": 0, + "type": "number" }, - "gas pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "Gas pipeline transmission efficiency.", - "type": "object" + "length_factor": { + "default": 1.25, + "description": "Multiplier applied to geometric corridor lengths.", + "exclusiveMinimum": 0, + "type": "number" }, - "electricity distribution grid": { - "additionalProperties": { - "type": "number" - }, - "description": "Electricity distribution grid efficiency.", - "type": "object" + "cost_factor": { + "default": 1, + "description": "Multiplier applied to the capital cost of transmission infrastructure.", + "exclusiveMinimum": 0, + "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } + } + } + }, + "_TransmissionEfficiencyConfig": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" } } }, @@ -9579,6 +9767,26 @@ "keep" ], "type": "string" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity.links.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for DC links.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.98, + "description": "Static DC transmission efficiency.", + "type": "number" + }, + "efficiency_per_1000km": { + "default": 0.977, + "description": "Distance-dependent DC efficiency factor per 1000 km.", + "type": "number" + } + } } } }, @@ -9681,12 +9889,26 @@ "default": 1.0, "description": "Multiplies the investment cost of the electricity distribution grid.", "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply electricity distribution efficiency.", + "type": "boolean" + }, + "efficiency_static": { + "default": 0.97, + "description": "Static electricity distribution efficiency.", + "type": "number" + } + } } }, "description": "Configuration for simplified electricity distribution grid." }, "hydrogen": { - "description": "Configuration for a single transmission carrier.", "properties": { "enable": { "default": true, @@ -9726,8 +9948,29 @@ "description": "Multiplier applied to the capital cost of transmission infrastructure.", "exclusiveMinimum": 0, "type": "number" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } } - } + }, + "description": "Configuration for hydrogen transmission candidates." }, "carbon_dioxide": { "description": "Configuration for a single transmission carrier.", @@ -9777,9 +10020,29 @@ "properties": { "enable": { "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" + }, + "efficiency": { + "description": "Configuration for `transmission..efficiency` settings.", + "properties": { + "enable": { + "default": true, + "description": "Apply transmission efficiency for this carrier.", + "type": "boolean" + }, + "efficiency_per_1000km": { + "default": 1, + "description": "Distance-dependent efficiency factor per 1000 km.", + "type": "number" + }, + "compression_per_1000km": { + "default": 0, + "description": "Additional electricity consumption for compression per 1000 km.", + "type": "number" + } + } } }, "description": "Configuration for methane gas transmission candidates." @@ -12052,51 +12315,11 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_grid_connection_costs": { + "electricity_grid_connection_cost": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "transmission_efficiency": { - "description": "Configuration for `sector.transmission_efficiency` settings.", - "properties": { - "enable": { - "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", - "items": { - "type": "string" - }, - "type": "array" - }, - "DC": { - "additionalProperties": { - "type": "number" - }, - "description": "DC transmission efficiency.", - "type": "object" - }, - "H2 pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "H2 pipeline transmission efficiency.", - "type": "object" - }, - "gas pipeline": { - "additionalProperties": { - "type": "number" - }, - "description": "Gas pipeline transmission efficiency.", - "type": "object" - }, - "electricity distribution grid": { - "additionalProperties": { - "type": "number" - }, - "description": "Electricity distribution grid efficiency.", - "type": "object" - } - } - }, "H2_retrofit": { "default": false, "description": "Add option for retrofiting existing pipelines to transport hydrogen.", diff --git a/config/test/config.tyndp.yaml b/config/test/config.tyndp.yaml index e137536ffb..4fffe83c79 100644 --- a/config/test/config.tyndp.yaml +++ b/config/test/config.tyndp.yaml @@ -58,13 +58,6 @@ renewable: max_depth: false min_depth: false -sector: - transmission_efficiency: - enable: - - H2 pipeline - - gas pipeline - - electricity distribution grid - clustering: mode: administrative administrative: diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index fb698e7398..e27dde19b9 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -10,7 +10,7 @@ from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from scripts.lib.validation.config._base import ConfigModel @@ -224,50 +224,6 @@ class _MethanolConfig(BaseModel): ) -class _TransmissionEfficiencyConfig(BaseModel): - """Configuration for `sector.transmission_efficiency` settings.""" - - enable: list[str] = Field( - default_factory=lambda: [ - "DC", - "H2 pipeline", - "gas pipeline", - "electricity distribution grid", - ], - description="Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", - ) - DC: dict[str, float] = Field( - default_factory=lambda: { - "efficiency_static": 0.98, - "efficiency_per_1000km": 0.977, - }, - description="DC transmission efficiency.", - ) - H2_pipeline: dict[str, float] = Field( - default_factory=lambda: { - "efficiency_per_1000km": 1, - "compression_per_1000km": 0.018, - }, - alias="H2 pipeline", - description="H2 pipeline transmission efficiency.", - ) - gas_pipeline: dict[str, float] = Field( - default_factory=lambda: { - "efficiency_per_1000km": 1, - "compression_per_1000km": 0.01, - }, - alias="gas pipeline", - description="Gas pipeline transmission efficiency.", - ) - electricity_distribution_grid: dict[str, float] = Field( - default_factory=lambda: {"efficiency_static": 0.97}, - alias="electricity distribution grid", - description="Electricity distribution grid efficiency.", - ) - - model_config = ConfigDict(populate_by_name=True) - - class _LimitMaxGrowthConfig(BaseModel): """Configuration for `sector.limit_max_growth` settings.""" @@ -799,16 +755,11 @@ class SectorConfig(BaseModel): description="Add option for using waste heat of electrolysis in district heating networks.", ) - electricity_grid_connection_costs: bool = Field( + electricity_grid_connection_cost: bool = Field( True, description="Add the cost of electricity grid connection for onshore wind and solar.", ) - transmission_efficiency: _TransmissionEfficiencyConfig = Field( - default_factory=_TransmissionEfficiencyConfig, - description="Transmission efficiency configuration.", - ) - H2_retrofit: bool = Field( False, description="Add option for retrofiting existing pipelines to transport hydrogen.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index 3416971009..fbad9fc0d7 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -94,6 +94,22 @@ class LinesConfig(BaseModel): class LinksConfig(ConfigModel): """Configuration for `links` settings.""" + class _DCEfficiencyConfig(BaseModel): + """Configuration for `transmission.electricity.links.efficiency` settings.""" + + enable: bool = Field( + True, + description="Apply transmission efficiency for DC links.", + ) + efficiency_static: float = Field( + 0.98, + description="Static DC transmission efficiency.", + ) + efficiency_per_1000km: float = Field( + 0.977, + description="Distance-dependent DC efficiency factor per 1000 km.", + ) + p_max_pu: float = Field( 1.0, description="Correction factor for link capacities `p_nom`.", @@ -118,6 +134,10 @@ class LinksConfig(ConfigModel): "keep", description="Specifies how to handle lines which are currently under construction.", ) + efficiency: _DCEfficiencyConfig = Field( + default_factory=_DCEfficiencyConfig, + description="DC transmission efficiency configuration.", + ) class TransformersConfig(ConfigModel): @@ -221,6 +241,23 @@ class _TransmissionCarrierConfigGeneral(BaseModel): ) +class _TransmissionEfficiencyConfig(BaseModel): + """Configuration for `transmission..efficiency` settings.""" + + enable: bool = Field( + True, + description="Apply transmission efficiency for this carrier.", + ) + efficiency_per_1000km: float = Field( + 1, + description="Distance-dependent efficiency factor per 1000 km.", + ) + compression_per_1000km: float = Field( + 0, + description="Additional electricity consumption for compression per 1000 km.", + ) + + class _TransmissionCarrierConfigElectricity(BaseModel): """Configuration for electricity transmission grid.""" @@ -257,11 +294,30 @@ class _TransmissionCarrierConfigElectricity(BaseModel): class _TransmissionCarrierConfigGas(BaseModel): enable: bool = Field( True, - description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + ) + efficiency: _TransmissionEfficiencyConfig = Field( + default_factory=lambda: _TransmissionEfficiencyConfig( + efficiency_per_1000km=1, + compression_per_1000km=0.01, + ), + description="Methane gas pipeline transmission efficiency.", ) class _TransmissionCarrierConfigElectricityDistribution(BaseModel): + class _ElectricityDistributionEfficiencyConfig(BaseModel): + """Configuration for `transmission.electricity_distribution.efficiency` settings.""" + + enable: bool = Field( + True, + description="Apply electricity distribution efficiency.", + ) + efficiency_static: float = Field( + 0.97, + description="Static electricity distribution efficiency.", + ) + enable: bool = Field( True, description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", @@ -270,6 +326,20 @@ class _TransmissionCarrierConfigElectricityDistribution(BaseModel): 1.0, description="Multiplies the investment cost of the electricity distribution grid.", ) + efficiency: _ElectricityDistributionEfficiencyConfig = Field( + default_factory=_ElectricityDistributionEfficiencyConfig, + description="Electricity distribution efficiency configuration.", + ) + + +class _TransmissionCarrierConfigHydrogen(_TransmissionCarrierConfigGeneral): + efficiency: _TransmissionEfficiencyConfig = Field( + default_factory=lambda: _TransmissionEfficiencyConfig( + efficiency_per_1000km=1, + compression_per_1000km=0.018, + ), + description="Hydrogen pipeline transmission efficiency.", + ) class TransmissionConfig(BaseModel): @@ -283,8 +353,8 @@ class TransmissionConfig(BaseModel): default_factory=_TransmissionCarrierConfigElectricityDistribution, description="Configuration for simplified electricity distribution grid.", ) - hydrogen: _TransmissionCarrierConfigGeneral = Field( - default_factory=_TransmissionCarrierConfigGeneral, + hydrogen: _TransmissionCarrierConfigHydrogen = Field( + default_factory=_TransmissionCarrierConfigHydrogen, description="Configuration for hydrogen transmission candidates.", ) carbon_dioxide: _TransmissionCarrierConfigGeneral = Field( diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index f265136ddb..a1ab80bad1 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1525,7 +1525,7 @@ def add_ammonia( def insert_electricity_distribution_grid( n: pypsa.Network, costs: pd.DataFrame, - options: dict, + cf_transmission: dict, pop_layout: pd.DataFrame, solar_rooftop_potentials_fn: str, ) -> None: @@ -1544,10 +1544,9 @@ def insert_electricity_distribution_grid( Technology cost assumptions with technologies as index and cost parameters as columns, including 'fixed' costs, 'lifetime', and component-specific parameters like 'efficiency' - options : dict - Configuration options containing at least: - - transmission_efficiency: dict with distribution grid parameters - - marginal_cost_storage: float for storage operation costs + cf_transmission : dict + Transmission configuration containing at least: + - electricity_distribution.efficiency: distribution grid loss parameters pop_layout : pd.DataFrame Population data per node with at least: - 'total' column containing population in thousands @@ -1598,13 +1597,10 @@ def insert_electricity_distribution_grid( # deduct distribution losses from electricity demand as these are included in total load # https://nbviewer.org/github/Open-Power-System-Data/datapackage_timeseries/blob/2020-10-06/main.ipynb - if ( - efficiency := options["transmission_efficiency"] - .get("electricity distribution grid", {}) - .get("efficiency_static") - ) and "electricity distribution grid" in options["transmission_efficiency"][ - "enable" - ]: + distribution_efficiency = cf_transmission["electricity_distribution"]["efficiency"] + if distribution_efficiency["enable"] and ( + efficiency := distribution_efficiency["efficiency_static"] + ): logger.info( f"Deducting distribution losses from electricity demand: {np.around(100 * (1 - efficiency), decimals=2)}%" ) @@ -1760,7 +1756,7 @@ def insert_gas_distribution_costs( n.links.loc[mchp, "capital_cost"] += capital_cost -def add_electricity_grid_connection_costs(n, costs): +def add_electricity_grid_connection_cost(n, costs): carriers = ["onwind", "solar", "solar-hsat"] gens = n.generators.index[n.generators.carrier.isin(carriers)] @@ -6610,7 +6606,11 @@ def add_import_options( if cf_transmission["electricity_distribution"]["enable"]: insert_electricity_distribution_grid( - n, costs, options, pop_layout, snakemake.input.solar_rooftop_potentials + n, + costs, + cf_transmission, + pop_layout, + snakemake.input.solar_rooftop_potentials, ) if options["enhanced_geothermal"].get("enable", False): @@ -6631,12 +6631,22 @@ def add_import_options( if options["gas_distribution_grid"]: insert_gas_distribution_costs(n, costs, options=options) - if options["electricity_grid_connection_costs"]: - add_electricity_grid_connection_costs(n, costs) + if options["electricity_grid_connection_cost"]: + add_electricity_grid_connection_cost(n, costs) + + transmission_efficiency_map = { + "DC": cf_transmission["electricity"]["links"]["efficiency"], + "H2 pipeline": cf_transmission["hydrogen"]["efficiency"], + "gas pipeline": cf_transmission["gas"]["efficiency"], + "electricity distribution grid": cf_transmission["electricity_distribution"][ + "efficiency" + ], + } - for k, v in options["transmission_efficiency"].items(): - if k in options["transmission_efficiency"]["enable"]: - lossy_bidirectional_links(n, k, v) + for carrier, efficiency_config in transmission_efficiency_map.items(): + if efficiency_config["enable"]: + losses = {k: v for k, v in efficiency_config.items() if k != "enable"} + lossy_bidirectional_links(n, carrier, losses) # Workaround: Remove lines with conflicting (and unrealistic) properties # cf. https://github.com/PyPSA/pypsa-eur/issues/444 From d050860c191af012780863fbc2281db34862b1b4 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 14:58:02 +0200 Subject: [PATCH 23/32] Renamed gas_distribution_grid to *_cost, as they do not actually add the gas distribution grid, only capital costs. Clean-up. --- config/config.default.yaml | 2 +- config/schema.default.json | 8 ++++---- scripts/lib/validation/config/sector.py | 5 +++-- scripts/prepare_sector_network.py | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index e6c705404f..20286a4b03 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -935,7 +935,7 @@ sector: H2_retrofit: false H2_retrofit_capacity_per_CH4: 0.6 gas_network_connectivity_upgrade: 1 - gas_distribution_grid: true + gas_distribution_grid_cost: true gas_distribution_grid_cost_factor: 1.0 biomass_spatial: true biomass_transport: false diff --git a/config/schema.default.json b/config/schema.default.json index bf73d3b35f..431f5f34ab 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4912,9 +4912,9 @@ "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", "type": "number" }, - "gas_distribution_grid": { + "gas_distribution_grid_cost": { "default": true, - "description": "Add a gas distribution grid.", + "description": "Add gas distribution grid costs to gas-consuming components.", "type": "boolean" }, "gas_distribution_grid_cost_factor": { @@ -12337,9 +12337,9 @@ "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", "type": "number" }, - "gas_distribution_grid": { + "gas_distribution_grid_cost": { "default": true, - "description": "Add a gas distribution grid.", + "description": "Add gas distribution grid costs to gas-consuming components.", "type": "boolean" }, "gas_distribution_grid_cost_factor": { diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index e27dde19b9..e4168363d8 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -772,8 +772,9 @@ class SectorConfig(BaseModel): 1, description="The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", ) - gas_distribution_grid: bool = Field( - True, description="Add a gas distribution grid." + gas_distribution_grid_cost: bool = Field( + True, + description="Add gas distribution grid costs to gas-consuming components.", ) gas_distribution_grid_cost_factor: float = Field( 1.0, diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index a1ab80bad1..0b2430eec2 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1697,7 +1697,7 @@ def insert_electricity_distribution_grid( ) -def insert_gas_distribution_costs( +def insert_gas_distribution_cost( n: pypsa.Network, costs: pd.DataFrame, options: dict, @@ -6628,8 +6628,8 @@ def add_import_options( if options["imports"]["enable"]: add_import_options(n, costs, options, gas_input_nodes) - if options["gas_distribution_grid"]: - insert_gas_distribution_costs(n, costs, options=options) + if options["gas_distribution_grid_cost"]: + insert_gas_distribution_cost(n, costs, options=options) if options["electricity_grid_connection_cost"]: add_electricity_grid_connection_cost(n, costs) From 30ba3f49d6190b316dd2ffbc6197dd82751a754e Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 15:33:34 +0200 Subject: [PATCH 24/32] H2 retrofit moved to transmission.hydrogen. Clean-up of params. --- config/config.default.yaml | 7 +- config/examples/config.iterative.yaml | 4 +- config/schema.default.json | 116 +++++++++++++----- config/test/config.overnight.yaml | 4 +- rules/build_sector.smk | 4 - rules/solve_myopic.smk | 6 +- rules/solve_perfect.smk | 1 + scripts/add_brownfield.py | 20 +-- scripts/lib/validation/config/sector.py | 13 -- scripts/lib/validation/config/transmission.py | 21 ++++ scripts/prepare_sector_network.py | 22 ++-- scripts/solve_network.py | 4 +- 12 files changed, 139 insertions(+), 83 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index 20286a4b03..50fc660cfd 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -258,6 +258,9 @@ transmission: 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 @@ -272,6 +275,7 @@ transmission: cost_factor: 1 gas: enable: true + connectivity_upgrade: 1 efficiency: enable: true efficiency_per_1000km: 1.0 @@ -932,9 +936,6 @@ sector: use_fuel_cell_waste_heat: 1 use_electrolysis_waste_heat: 0.25 electricity_grid_connection_cost: true - H2_retrofit: false - H2_retrofit_capacity_per_CH4: 0.6 - gas_network_connectivity_upgrade: 1 gas_distribution_grid_cost: true gas_distribution_grid_cost_factor: 1.0 biomass_spatial: true diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index b8c7374ada..4a5a2a5a9e 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -37,6 +37,9 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] transmission: + hydrogen: + retrofit: + enable: true carbon_dioxide: enable: false gas: @@ -65,7 +68,6 @@ clustering: resolution_sector: 24h sector: - H2_retrofit: true district_heating: ptes: supplemental_heating: diff --git a/config/schema.default.json b/config/schema.default.json index 431f5f34ab..de89d6bd45 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -4895,23 +4895,6 @@ "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "H2_retrofit": { - "default": false, - "description": "Add option for retrofiting existing pipelines to transport hydrogen.", - "type": "boolean" - }, - "H2_retrofit_capacity_per_CH4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "type": "number" - }, - "gas_network_connectivity_upgrade": { - "default": 1, - "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", - "type": "number" - }, "gas_distribution_grid_cost": { "default": true, "description": "Add gas distribution grid costs to gas-consuming components.", @@ -5946,6 +5929,22 @@ "exclusiveMinimum": 0, "type": "number" }, + "retrofit": { + "description": "Configuration for `transmission.hydrogen.retrofit` settings.", + "properties": { + "enable": { + "default": false, + "description": "Add option for retrofitting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "capacity_per_ch4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + } + } + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -6021,6 +6020,12 @@ "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" }, + "connectivity_upgrade": { + "default": 1, + "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", + "type": "number" + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -7122,6 +7127,22 @@ } } }, + "_HydrogenRetrofitConfig": { + "description": "Configuration for `transmission.hydrogen.retrofit` settings.", + "properties": { + "enable": { + "default": false, + "description": "Add option for retrofitting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "capacity_per_ch4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + } + } + }, "_ImportsConfig": { "description": "Configuration for `sector.imports` settings.", "properties": { @@ -8805,6 +8826,12 @@ "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" }, + "connectivity_upgrade": { + "default": 1, + "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", + "type": "number" + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -8912,6 +8939,22 @@ "exclusiveMinimum": 0, "type": "number" }, + "retrofit": { + "description": "Configuration for `transmission.hydrogen.retrofit` settings.", + "properties": { + "enable": { + "default": false, + "description": "Add option for retrofitting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "capacity_per_ch4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + } + } + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -9949,6 +9992,22 @@ "exclusiveMinimum": 0, "type": "number" }, + "retrofit": { + "description": "Configuration for `transmission.hydrogen.retrofit` settings.", + "properties": { + "enable": { + "default": false, + "description": "Add option for retrofitting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "capacity_per_ch4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + } + } + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -10024,6 +10083,12 @@ "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", "type": "boolean" }, + "connectivity_upgrade": { + "default": 1, + "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", + "type": "number" + }, "efficiency": { "description": "Configuration for `transmission..efficiency` settings.", "properties": { @@ -12320,23 +12385,6 @@ "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "H2_retrofit": { - "default": false, - "description": "Add option for retrofiting existing pipelines to transport hydrogen.", - "type": "boolean" - }, - "H2_retrofit_capacity_per_CH4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "type": "number" - }, - "gas_network_connectivity_upgrade": { - "default": 1, - "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", - "type": "number" - }, "gas_distribution_grid_cost": { "default": true, "description": "Add gas distribution grid costs to gas-consuming components.", diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index 4a4f292d6a..b83fbb8a01 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -37,6 +37,9 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] transmission: + hydrogen: + retrofit: + enable: true carbon_dioxide: enable: false gas: @@ -65,7 +68,6 @@ clustering: resolution_sector: 24h sector: - H2_retrofit: true residential_heat: dsm: enable: true diff --git a/rules/build_sector.smk b/rules/build_sector.smk index bdf6dc80bc..f247f34179 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1795,11 +1795,7 @@ rule prepare_sector_network: sector=config_provider("sector"), industry=config_provider("industry"), renewable=config_provider("renewable"), - lines=config_provider("transmission", "electricity", "lines"), pypsa_eur=config_provider("pypsa_eur"), - length_factor=config_provider( - "transmission", "electricity", "lines", "length_factor" - ), planning_horizons=config_provider("scenario", "planning_horizons"), countries=config_provider("countries"), adjustments=config_provider("adjustments", "sector"), diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 65eedfb104..738b8e58a3 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -83,10 +83,7 @@ rule add_brownfield: resources: mem_mb=10000, params: - H2_retrofit=config_provider("sector", "H2_retrofit"), - H2_retrofit_capacity_per_CH4=config_provider( - "sector", "H2_retrofit_capacity_per_CH4" - ), + transmission=config_provider("transmission"), threshold_capacity=config_provider("existing_capacities", "threshold_capacity"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), @@ -142,6 +139,7 @@ rule solve_sector_network_myopic: params: solving=config_provider("solving"), foresight=config_provider("foresight"), + transmission=config_provider("transmission"), co2_sequestration_potential=config_provider( "sector", "co2_sequestration_potential", default=200 ), diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index 8f07ff59d8..389fc6f1b0 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -126,6 +126,7 @@ rule solve_sector_network_perfect: params: solving=config_provider("solving"), foresight=config_provider("foresight"), + transmission=config_provider("transmission"), sector=config_provider("sector"), planning_horizons=config_provider("scenario", "planning_horizons"), co2_sequestration_potential=config_provider( diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index d1a28bb45b..233412d2ae 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -30,8 +30,7 @@ def add_brownfield( n, n_p, year, - h2_retrofit=False, - h2_retrofit_capacity_per_ch4=None, + cf_transmission=None, capacity_threshold=None, ): """ @@ -45,14 +44,16 @@ def add_brownfield( Previous network to get brownfield from year : int Planning year - h2_retrofit : bool - Whether to allow hydrogen pipeline retrofitting - h2_retrofit_capacity_per_ch4 : float - Ratio of hydrogen to methane capacity for pipeline retrofitting + cf_transmission : dict + Transmission configuration containing hydrogen retrofit settings capacity_threshold : float Threshold for removing assets with low capacity """ logger.info(f"Preparing brownfield for the year {year}") + hydrogen_retrofit = cf_transmission["hydrogen"]["retrofit"]["enable"] + hydrogen_retrofit_capacity_per_ch4 = cf_transmission["hydrogen"]["retrofit"][ + "capacity_per_ch4" + ] # electric transmission grid set optimised capacities of previous as minimum n.lines.s_nom_min = n_p.lines.s_nom_opt @@ -127,7 +128,7 @@ def add_brownfield( n._import_series_from_df(c.dynamic[tattr], c.name, tattr) # deal with gas network - if h2_retrofit: + if hydrogen_retrofit: # subtract the already retrofitted from the maximum capacity h2_retrofitted_fixed_i = n.links[ (n.links.carrier == "H2 pipeline retrofitted") @@ -161,7 +162,7 @@ def add_brownfield( pipe_capacity = n.links.loc[gas_pipes_i, "p_nom"] fr = "H2 pipeline retrofitted" to = "gas pipeline" - CH4_per_H2 = 1 / h2_retrofit_capacity_per_ch4 + CH4_per_H2 = 1 / hydrogen_retrofit_capacity_per_ch4 already_retrofitted.index = already_retrofitted.index.str.replace(fr, to) remaining_capacity = ( pipe_capacity @@ -381,8 +382,7 @@ def update_dynamic_ptes_capacity( n, n_p, year, - h2_retrofit=snakemake.params.H2_retrofit, - h2_retrofit_capacity_per_ch4=snakemake.params.H2_retrofit_capacity_per_CH4, + cf_transmission=snakemake.params.transmission, capacity_threshold=snakemake.params.threshold_capacity, ) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index e4168363d8..a546d05d1c 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -759,19 +759,6 @@ class SectorConfig(BaseModel): True, description="Add the cost of electricity grid connection for onshore wind and solar.", ) - - H2_retrofit: bool = Field( - False, - description="Add option for retrofiting existing pipelines to transport hydrogen.", - ) - H2_retrofit_capacity_per_CH4: float = Field( - 0.6, - description="The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - ) - gas_network_connectivity_upgrade: float = Field( - 1, - description="The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - ) gas_distribution_grid_cost: bool = Field( True, description="Add gas distribution grid costs to gas-consuming components.", diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index fbad9fc0d7..0c06bcf68d 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -258,6 +258,19 @@ class _TransmissionEfficiencyConfig(BaseModel): ) +class _HydrogenRetrofitConfig(BaseModel): + """Configuration for `transmission.hydrogen.retrofit` settings.""" + + enable: bool = Field( + False, + description="Add option for retrofitting existing pipelines to transport hydrogen.", + ) + capacity_per_ch4: float = Field( + 0.6, + description="The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + ) + + class _TransmissionCarrierConfigElectricity(BaseModel): """Configuration for electricity transmission grid.""" @@ -296,6 +309,10 @@ class _TransmissionCarrierConfigGas(BaseModel): True, description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", ) + connectivity_upgrade: float = Field( + 1, + description="The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + ) efficiency: _TransmissionEfficiencyConfig = Field( default_factory=lambda: _TransmissionEfficiencyConfig( efficiency_per_1000km=1, @@ -333,6 +350,10 @@ class _ElectricityDistributionEfficiencyConfig(BaseModel): class _TransmissionCarrierConfigHydrogen(_TransmissionCarrierConfigGeneral): + retrofit: _HydrogenRetrofitConfig = Field( + default_factory=_HydrogenRetrofitConfig, + description="Hydrogen pipeline retrofit configuration.", + ) efficiency: _TransmissionEfficiencyConfig = Field( default_factory=lambda: _TransmissionEfficiencyConfig( efficiency_per_1000km=1, diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 0b2430eec2..38771fc0f4 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1808,8 +1808,6 @@ def add_h2_gas_infrastructure( - hydrogen_fuel_cell : bool - hydrogen_turbine : bool - hydrogen_underground_storage : bool - - gas_network : bool - - H2_retrofit : bool - methanation : bool - coal_cc : bool - SMR_cc : bool @@ -1834,6 +1832,8 @@ def add_h2_gas_infrastructure( """ # Set defaults options = options or {} + hydrogen_retrofit = cf_transmission["hydrogen"]["retrofit"] + gas_connectivity_upgrade = cf_transmission["gas"]["connectivity_upgrade"] logger.info("Add hydrogen storage") @@ -1941,7 +1941,7 @@ def add_h2_gas_infrastructure( lifetime=costs.at[tech, "lifetime"], ) - if options["H2_retrofit"]: + if hydrogen_retrofit["enable"]: gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) if cf_transmission["gas"]["enable"]: @@ -1960,7 +1960,7 @@ def add_h2_gas_infrastructure( gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) - if options["H2_retrofit"]: + if hydrogen_retrofit["enable"]: gas_pipes["p_nom_max"] = gas_pipes.p_nom gas_pipes["p_nom_min"] = 0.0 # 0.1 EUR/MWkm/a to prefer decommissioning to address degeneracy @@ -2041,7 +2041,7 @@ def add_h2_gas_infrastructure( ) # apply k_edge_augmentation weighted by length of complement edges - k_edge = options["gas_network_connectivity_upgrade"] + k_edge = gas_connectivity_upgrade if augmentation := list( k_edge_augmentation(G, k_edge, avail=complement_edges.values) ): @@ -2068,7 +2068,7 @@ def add_h2_gas_infrastructure( lifetime=costs.at["CH4 (g) pipeline", "lifetime"], ) - if options["H2_retrofit"]: + if hydrogen_retrofit["enable"]: logger.info("Add retrofitting options of existing CH4 pipes to H2 pipes.") fr = "gas pipeline" @@ -2081,7 +2081,7 @@ def add_h2_gas_infrastructure( bus0=h2_pipes["bus0"] + " H2", bus1=h2_pipes["bus1"] + " H2", p_min_pu=-1.0, # allow that all H2 retrofit pipelines can be used in both directions - p_nom_max=h2_pipes["p_nom"] * options["H2_retrofit_capacity_per_CH4"], + p_nom_max=h2_pipes["p_nom"] * hydrogen_retrofit["capacity_per_ch4"], p_nom_extendable=True, length=h2_pipes["length"], capital_cost=costs.at["H2 (g) pipeline repurposed", "capital_cost"] @@ -4518,7 +4518,6 @@ def add_industry( cf_industry: dict, cf_transmission: dict, investment_year: int, - co2_transmission_enable: bool, ): """ Add industry and their corresponding carrier buses to the network. @@ -5060,7 +5059,7 @@ def add_industry( unit="t_co2", ) - if options["co2_spatial"] or co2_transmission_enable: + if options["co2_spatial"] or cf_transmission["carbon_dioxide"]["enable"]: p_set = ( -industrial_demand.loc[nodes, "process emission"].rename( index=lambda x: x + " process emissions" @@ -6503,7 +6502,6 @@ def add_import_options( cf_industry=cf_industry, cf_transmission=cf_transmission, investment_year=investment_year, - co2_transmission_enable=cf_transmission["carbon_dioxide"]["enable"], ) if options["shipping"]: @@ -6548,7 +6546,7 @@ def add_import_options( if not cf_transmission["electricity"]["enable"]: decentral(n) - if not snakemake.config["transmission"]["hydrogen"]["enable"]: + if not cf_transmission["hydrogen"]["enable"]: remove_h2_network(n) if cf_transmission["carbon_dioxide"]["enable"]: @@ -6600,7 +6598,7 @@ def add_import_options( limit, ) - maxext = snakemake.params["lines"]["max_extension"] + maxext = cf_transmission["electricity"]["lines"]["max_extension"] if maxext is not None: limit_individual_line_extension(n, maxext) diff --git a/scripts/solve_network.py b/scripts/solve_network.py index 4b6429b47c..59132e3fa3 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1137,7 +1137,9 @@ def add_pipe_retrofit_constraint(n): p_nom = n.model["Link-p_nom"] - CH4_per_H2 = 1 / n.config["sector"]["H2_retrofit_capacity_per_CH4"] + CH4_per_H2 = ( + 1 / n.params["transmission"]["hydrogen"]["retrofit"]["capacity_per_ch4"] + ) lhs = p_nom.loc[gas_pipes_i] + CH4_per_H2 * p_nom.loc[h2_retrofitted_i] rhs = n.links.p_nom[gas_pipes_i] if not PYPSA_V1: From 87e13235fd6f300532723a16839d11ccaadc3ed6 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 15:54:53 +0200 Subject: [PATCH 25/32] Bug fix in solve_overnight.smk params and added release notes. --- doc/release_notes.rst | 20 ++++++++++++++++++++ rules/solve_overnight.smk | 1 + 2 files changed, 21 insertions(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index c707e83566..f912d677d5 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -22,6 +22,26 @@ Release Notes * feat: Improve the config validation to cover scenario management (https://github.com/PyPSA/pypsa-eur/pull/2155). +* feat: Add new rule :mod:`build_transmission_topology` to generate greenfield pipeline investment candidates + (H2, CO2) using Delaunay triangulation with optional Gabriel graph filtering and minimum-degree backfilling, + replacing the former AC-topology-based approach. A ``max_offshore_haversine_distance`` setting per carrier + limits offshore corridor lengths (https://github.com/PyPSA/pypsa-eur/pull/2153). + +* refactor: Rename ``transmission_projects`` to ``transmission.electricity.projects`` and + ``gas_distribution_grid`` to ``gas_distribution_grid_cost`` to better reflect their semantics + (https://github.com/PyPSA/pypsa-eur/pull/2153). Rename `sector.electricity_grid_connection` to `sector.electricity_grid_connection_cost` + and `gas_distribution_grid` to `gas_distribution_grid_cost`. + +**Breaking changes** + +* Consolidate all carrier-specific transmission settings under a new top-level ``transmission`` config + key with sub-keys per carrier (``electricity``, ``hydrogen``, ``carbon_dioxide``, ``gas``, + ``electricity_distribution``), replacing the previously scattered ``lines``, ``links``, ``transformers``, + ``transmission_projects``, ``base_network``, ``transmission_limit``, and sector-level keys such as + ``H2_network``, ``gas_network``, ``co2_network``, ``transmission_efficiency``, ``H2_retrofit``, and + ``gas_distribution_grid``. Carrier efficiencies, H2 retrofit settings, and distribution grid costs are + now nested under the respective carrier sub-key (https://github.com/PyPSA/pypsa-eur/pull/2153). + PyPSA-Eur v2026.02.0 (18th February 2026) ========================================= diff --git a/rules/solve_overnight.smk b/rules/solve_overnight.smk index 0733312440..3e58eb827c 100644 --- a/rules/solve_overnight.smk +++ b/rules/solve_overnight.smk @@ -40,6 +40,7 @@ rule solve_sector_network: params: solving=config_provider("solving"), foresight=config_provider("foresight"), + transmission=config_provider("transmission"), co2_sequestration_potential=config_provider( "sector", "co2_sequestration_potential", default=200 ), From 1e1d52f2f7ef54b8f7e4215f0278126e5dd8cced Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 24 Apr 2026 16:00:13 +0200 Subject: [PATCH 26/32] Clean up mock snakemakes. --- scripts/build_transmission_topology.py | 4 +--- scripts/prepare_sector_network.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index dd82441106..1f646b66f7 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -527,9 +527,7 @@ def add_underwater_fraction( snakemake = mock_snakemake( "build_transmission_topology", - clusters="200", - run="nodes200", - configfiles=["config/config.200.yaml"], + clusters="50", ) configure_logging(snakemake) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 38771fc0f4..d02e797ad9 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -6283,10 +6283,9 @@ def add_import_options( snakemake = mock_snakemake( "prepare_sector_network", opts="", - clusters="200", + clusters="50", sector_opts="", planning_horizons="2050", - configfiles=["config/config.200.yaml"], ) configure_logging(snakemake) # pylint: disable=E0606 From 29ea909154cdc39040238bbbe88784dd71ff6f97 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 27 Apr 2026 15:22:08 +0200 Subject: [PATCH 27/32] Implemented review comments, splitting into two rules, config call clean up, etc. --- config/config.default.yaml | 8 +- rules/build_sector.smk | 91 ++-- scripts/build_transmission_delaunay_graph.py | 316 ++++++++++++ scripts/build_transmission_topology.py | 479 +++--------------- scripts/lib/validation/config/transmission.py | 21 +- scripts/prepare_sector_network.py | 11 +- 6 files changed, 432 insertions(+), 494 deletions(-) create mode 100644 scripts/build_transmission_delaunay_graph.py diff --git a/config/config.default.yaml b/config/config.default.yaml index 50fc660cfd..e303c53379 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -252,9 +252,7 @@ transmission: efficiency_static: 0.97 hydrogen: enable: true - gabriel_filter: - enable: true - min_degree: 1 + gabriel_filter_min_degree: 1 max_offshore_haversine_distance: .inf length_factor: 1.25 cost_factor: 1 @@ -267,9 +265,7 @@ transmission: compression_per_1000km: 0.018 carbon_dioxide: enable: true - gabriel_filter: - enable: true - min_degree: 1 + gabriel_filter_min_degree: 1 max_offshore_haversine_distance: .inf length_factor: 1.25 cost_factor: 1 diff --git a/rules/build_sector.smk b/rules/build_sector.smk index f247f34179..d9b736b69d 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -148,59 +148,46 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") -def transmission_candidate_specs() -> list[tuple[int, str]]: - specs = set() - - for carrier_config in config.get("transmission", {}).values(): - if not isinstance(carrier_config, dict): - continue - if not carrier_config.get("enable", False): - continue - gabriel_filter = carrier_config.get("gabriel_filter", {}) - if not isinstance(gabriel_filter, dict): - continue - if not gabriel_filter.get("enable", False): - continue - if "min_degree" not in gabriel_filter: - continue - - min_degree = int(gabriel_filter["min_degree"]) - max_offdist = f"{float(carrier_config.get('max_offshore_haversine_distance', float('inf'))):g}" - specs.add((min_degree, max_offdist)) - - return sorted(specs, key=lambda spec: (spec[0], float(spec[1]))) - - -rule build_transmission_topology: +rule build_transmission_delaunay_graph: input: network=resources("networks/base_s_{clusters}.nc"), offshore_shapes=resources("offshore_shapes.geojson"), output: - all_edges=resources("transmission/all_edges_{clusters}.geojson"), - candidates=[ - resources( - "transmission/candidates_{clusters}_min_" - + f"{int(min_degree)}_maxoffdist_{max_offdist}_km.geojson" - ) - for min_degree, max_offdist in transmission_candidate_specs() - ], + delaunay_graph=resources("transmission/delaunay_graph_{clusters}.geojson"), log: - logs("build_transmission_topology_{clusters}.log"), + logs("build_transmission_delaunay_graph_{clusters}.log"), benchmark: - benchmarks("build_transmission_topology/{clusters}") + benchmarks("build_transmission_delaunay_graph/{clusters}") resources: mem_mb=4000, + message: + "Building Delaunay triangulation graph for {wildcards.clusters} clusters" + script: + scripts("build_transmission_delaunay_graph.py") + + +rule build_transmission_topology: + input: + delaunay_graph=resources("transmission/delaunay_graph_{clusters}.geojson"), + output: + candidates=resources( + "transmission/candidates_{clusters}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson" + ), + log: + logs( + "build_transmission_topology_{clusters}_min_{min_degree}_maxoffdist_{max_offdist}.log" + ), + benchmark: + benchmarks( + "build_transmission_topology/{clusters}_min_{min_degree}_maxoffdist_{max_offdist}" + ) + resources: + mem_mb=2000, params: - transmission=config_provider("transmission"), - candidate_specs=[ - { - "min_degree": int(min_degree), - "max_offshore_haversine_distance_km": float(max_offdist), - } - for min_degree, max_offdist in transmission_candidate_specs() - ], + min_degree=lambda w: int(w.min_degree), + max_offdist=lambda w: float(w.max_offdist), message: - "Building transmission candidates for {wildcards.clusters} clusters" + "Building transmission topology for {wildcards.clusters} clusters (min_degree={wildcards.min_degree}, max_offdist={wildcards.max_offdist} km)" script: scripts("build_transmission_topology.py") @@ -1624,21 +1611,21 @@ def input_heat_source_power(w): def input_transmission_candidates(w): """Generate transmission candidate file inputs for enabled transmission carriers.""" candidates = {} - 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): + for carrier in ("hydrogen", "carbon_dioxide"): + carrier_cfg = config["transmission"][carrier] + if not carrier_cfg["enable"]: 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}" + min_degree = carrier_cfg["gabriel_filter_min_degree"] + max_offdist = carrier_cfg["max_offshore_haversine_distance"] + if min_degree > 0 or max_offdist < float("inf"): candidates[key] = resources( f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson" ) else: - candidates[key] = resources("transmission/all_edges_{clusters}.geojson") + candidates[key] = resources( + "transmission/delaunay_graph_{clusters}.geojson" + ) return candidates diff --git a/scripts/build_transmission_delaunay_graph.py b/scripts/build_transmission_delaunay_graph.py new file mode 100644 index 0000000000..50d49310a9 --- /dev/null +++ b/scripts/build_transmission_delaunay_graph.py @@ -0,0 +1,316 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Build the Delaunay triangulation graph for transmission corridor candidates. + +Description +----------- +Creates the full Delaunay edge table from clustered network bus coordinates, +enriched with Gabriel edge flags and offshore underwater fractions. + +The output GeoDataFrame is consumed by build_transmission_topology to filter +candidate edges per carrier configuration. + +Outputs +------- +GeoJSON file in WGS84 (EPSG:4326) format: + +``delaunay_graph`` + Full Delaunay edge table with columns: + + - ``name``: Canonical undirected edge identifier ``"bus0 -> bus1"``. + - ``bus0``: Canonically ordered first bus id (lexicographic order). + - ``bus1``: Canonically ordered second bus id. + - ``source``: Integer index of the first bus. + - ``target``: Integer index of the second bus. + - ``length``: Great-circle edge length in km. + - ``gabriel_edge``: ``True`` if the edge satisfies the Gabriel empty + circle criterion. + - ``underwater_fraction``: Fraction of edge length over offshore regions. + - ``geometry``: LineString geometry in ``EPSG:4326``. +""" + +import logging + +import geopandas as gpd +import numpy as np +import pandas as pd +import pypsa +from numpy.typing import NDArray +from pypsa.geo import haversine_pts +from scipy.spatial import Delaunay, QhullError +from shapely.geometry import LineString + +from scripts._helpers import configure_logging, set_scenario_config + +logger = logging.getLogger(__name__) + +GEO_CRS = "EPSG:4326" +DISTANCE_CRS = "EPSG:3035" + + +def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: + """ + Build sorted unique undirected edges from Delaunay simplices. + + Parameters + ---------- + coords_meter : NDArray[np.float64] + Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. + + Returns + ------- + list[tuple[int, int]] + Sorted list of unique undirected edges represented as index pairs + ``(u, v)`` with ``u < v``. + + Raises + ------ + ValueError + If fewer than three points are provided. + """ + if len(coords_meter) < 3: + raise ValueError("Need at least 3 points to compute Delaunay triangulation.") + + try: + tri = Delaunay(coords_meter) + except QhullError: + # Robust fallback for nearly degenerate point sets. + tri = Delaunay(coords_meter, qhull_options="QJ") + + edges: set[tuple[int, int]] = set() + for simplex in tri.simplices: + for i, j in ((0, 1), (1, 2), (0, 2)): + edges.add(tuple(sorted((int(simplex[i]), int(simplex[j]))))) + return sorted(edges) + + +def pairwise_sq_dists(coords: NDArray[np.float64]) -> NDArray[np.float64]: + """ + Compute the squared Euclidean distance matrix. + + Parameters + ---------- + coords : NDArray[np.float64] + Node coordinates in Euclidean space with shape ``(n_nodes, n_dims)``. + + Returns + ------- + NDArray[np.float64] + Dense matrix ``D`` with shape ``(n_nodes, n_nodes)`` where + ``D[i, j] = ||coords[i] - coords[j]||^2``. + """ + norms = np.sum(coords * coords, axis=1) + sq = norms[:, None] + norms[None, :] - 2.0 * (coords @ coords.T) + return np.maximum(sq, 0.0) + + +def classify_gabriel_edges( + edges: NDArray[np.int64], + coords_meter: NDArray[np.float64], + eps: float = 1e-9, + chunk_size: int = 1024, +) -> NDArray[np.bool_]: + """ + Classify candidate edges as Gabriel or non-Gabriel. + + Parameters + ---------- + edges : NDArray[np.int64] + Candidate undirected edges as node index pairs with shape ``(n_edges, 2)``. + coords_meter : NDArray[np.float64] + Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. + eps : float, default 1e-9 + Numerical tolerance used in the Gabriel emptiness test. + chunk_size : int, default 1024 + Batch size used to limit temporary memory during vectorized checks. + + Returns + ------- + NDArray[np.bool_] + Boolean mask of shape ``(n_edges,)`` where ``True`` marks Gabriel edges. + """ + sq_dists = pairwise_sq_dists(coords_meter) + edge_count = edges.shape[0] + is_gabriel = np.ones(edge_count, dtype=bool) + + for start in range(0, edge_count, chunk_size): + end = min(start + chunk_size, edge_count) + uv = edges[start:end] + u = uv[:, 0] + v = uv[:, 1] + dij2 = sq_dists[u, v] + + # A point k invalidates edge (u, v) when dik^2 + djk^2 < dij^2. + inside = (sq_dists[u, :] + sq_dists[v, :]) < (dij2[:, None] - eps) + row = np.arange(end - start) + inside[row, u] = False + inside[row, v] = False + is_gabriel[start:end] = ~inside.any(axis=1) + + return is_gabriel + + +def get_bus_coordinates( + n: pypsa.Network, +) -> tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]]: + """ + Extract bus ids and coordinates in geographic and metric CRS. + + Parameters + ---------- + n : pypsa.Network + Input network containing bus coordinate columns ``x`` and ``y``. + + Returns + ------- + tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]] + Tuple ``(bus_ids, coords_geo, coords_meter)`` where ``coords_geo`` is in + ``EPSG:4326`` and ``coords_meter`` is in ``EPSG:3035``. + + Raises + ------ + ValueError + If fewer than two buses with valid coordinates are available. + """ + buses = n.buses.copy() + buses = buses.dropna(subset=["x", "y"]) + + if len(buses) < 2: + raise ValueError("Need at least 2 buses with coordinates to build corridors.") + + points_geo = gpd.points_from_xy(buses["x"], buses["y"], crs=GEO_CRS) + points_meter = gpd.GeoSeries(points_geo, crs=GEO_CRS).to_crs(DISTANCE_CRS) + + coords_geo = np.column_stack( + (np.asarray(points_geo.x), np.asarray(points_geo.y)) + ).astype(float) + coords_meter = np.column_stack( + (np.asarray(points_meter.x), np.asarray(points_meter.y)) + ).astype(float) + + return buses.index, coords_geo, coords_meter + + +def delaunay_triangulation( + bus_ids: pd.Index, + coords_geo: NDArray[np.float64], + coords_meter: NDArray[np.float64], +) -> gpd.GeoDataFrame: + """ + Build the full Delaunay edge table with geometry and metadata. + + Parameters + ---------- + bus_ids : pd.Index + Bus identifiers aligned with the coordinate arrays. + coords_geo : NDArray[np.float64] + Geographic coordinates (longitude, latitude) in ``EPSG:4326``. + coords_meter : NDArray[np.float64] + Projected coordinates in ``EPSG:3035`` used for metric calculations. + + Returns + ------- + gpd.GeoDataFrame + Delaunay edge table including integer node endpoints, normalized bus + names, canonical edge ``name``, ``length`` (haversine distance, km), + ``gabriel_edge`` flag, and LineString geometry. + """ + + edges = delaunay_edges(coords_meter) + edges_arr = np.array(edges, dtype=np.int64) + u = edges_arr[:, 0] + v = edges_arr[:, 1] + + logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) + lengths = haversine_pts(coords_geo[u], coords_geo[v]) + gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) + edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] + + bus0 = bus_ids.take(u).astype(str).to_numpy() + bus1 = bus_ids.take(v).astype(str).to_numpy() + + # Keep undirected edge naming compatible with topology naming in + # prepare_sector_network.create_network_topology. + swap_mask = bus0 > bus1 + bus0_norm = np.where(swap_mask, bus1, bus0) + bus1_norm = np.where(swap_mask, bus0, bus1) + connector = " -> " + edge_names = np.char.add( + np.char.add(bus0_norm, connector), + bus1_norm, + ) + + edges_gdf = gpd.GeoDataFrame( + { + "source": u, + "target": v, + "bus0": bus0_norm, + "bus1": bus1_norm, + "name": edge_names, + "length": lengths, + "gabriel_edge": gabriel_flags, + "geometry": edge_geoms, + }, + geometry="geometry", + crs=GEO_CRS, + ) + return edges_gdf + + +def add_underwater_fraction( + edges: gpd.GeoDataFrame, + offshore_shapes: gpd.GeoDataFrame, +) -> gpd.GeoDataFrame: + """ + Compute the fraction of each edge's length that lies within offshore regions. + + Parameters + ---------- + edges : gpd.GeoDataFrame + Edge table with LineString geometry in ``EPSG:4326``. + offshore_shapes : gpd.GeoDataFrame + Offshore region polygons used to determine submarine sections. + + Returns + ------- + gpd.GeoDataFrame + ``edges`` with an added ``underwater_fraction`` column in ``[0, 1]``. + """ + offshore_union = offshore_shapes.to_crs(DISTANCE_CRS).union_all() + geoms = edges.geometry.to_crs(DISTANCE_CRS) + edges["underwater_fraction"] = ( + (geoms.intersection(offshore_union).length / geoms.length).fillna(0.0).round(2) + ) + return edges + + +if __name__ == "__main__": + is_mock_run = "snakemake" not in globals() + + if is_mock_run: + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_transmission_delaunay_graph", + clusters="50", + ) + + configure_logging(snakemake) + set_scenario_config(snakemake) + + n = pypsa.Network(snakemake.input.network) + offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) + + bus_ids, coords_geo, coords_meter = get_bus_coordinates(n) + delaunay_graph = delaunay_triangulation( + bus_ids=bus_ids, + coords_geo=coords_geo, + coords_meter=coords_meter, + ) + + add_underwater_fraction(delaunay_graph, offshore_shapes) + + delaunay_graph.to_file(snakemake.output.delaunay_graph) diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index 1f646b66f7..e9de383726 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -3,68 +3,39 @@ # SPDX-License-Identifier: MIT """ -Build transmission corridor topology/candidates from clustered network -bus coordinates using Delaunay triangulation and optional Gabriel graph -filtering. +Filter the Delaunay graph to transmission topology candidates. Description ----------- -Creates the topology for potential investment corridors between -clustered buses, i.e. for H2 and CO2 pipeline candidates. - -The script computes the Delaunay triangulation of the bus coordinates -and optionally filters edges to retain only Gabriel edges, which are -more likely to represent direct connections. A minimum degree -constraint can be enforced to ensure network connectivity and to avoid -stubs. +Reads the full Delaunay edge table produced by build_transmission_delaunay_graph +and applies Gabriel graph filtering and min-degree backfilling to produce a +candidate edge set for one (min_degree, max_offshore_haversine_distance) pair. Outputs ------- -GeoJSON files in WGS84 (EPSG:4326) format are written per cluster configuration: - -1. ``all_edges`` - Full Delaunay edge table (one row per Delaunay edge) with columns: - - - ``name``: Canonical undirected edge identifier ``"bus0 -> bus1"``. - - ``bus0``: Canonically ordered first bus id (lexicographic order). - - ``bus1``: Canonically ordered second bus id. - - ``length``: Great-circle edge length in km. - - ``gabriel_edge``: ``True`` if the edge satisfies the Gabriel empty - circle criterion. - - ``selected_edge``: ``True`` if the edge is part of the final - candidate set after filtering/backfilling. - - ``geometry``: LineString geometry in ``EPSG:4326``. - -2. ``candidates`` - One selected candidate corridor table per configured ``min_degree`` - (subset of Delaunay edges) with columns: - - - ``name`` - - ``bus0`` - - ``bus1`` - - ``length`` - - ``gabriel_edge`` - - ``geometry`` - +GeoJSON file in WGS84 (EPSG:4326) format: + +``candidates`` + Selected candidate corridor table (subset of Delaunay edges) with columns: + + - ``name`` + - ``bus0`` + - ``bus1`` + - ``length`` + - ``gabriel_edge`` + - ``underwater_fraction`` + - ``geometry`` """ import logging import geopandas as gpd import numpy as np -import pandas as pd -import pypsa -from numpy.typing import NDArray -from pypsa.geo import haversine_pts -from scipy.spatial import Delaunay, QhullError -from shapely.geometry import LineString from scripts._helpers import configure_logging, set_scenario_config logger = logging.getLogger(__name__) -GEO_CRS = "EPSG:4326" -DISTANCE_CRS = "EPSG:3035" COLS_EDGES = [ "name", "bus0", @@ -76,258 +47,7 @@ ] -def collect_transmission_topology_settings( - transmission_cfg: dict, -) -> bool: - """ - Collect shared topology settings from carriers with gabriel_filter.min_degree. - - Returns - ------- - bool - Shared ``gabriel_filter_enabled`` value. - """ - relevant = [] - for _, carrier_cfg in transmission_cfg.items(): - if not isinstance(carrier_cfg, dict): - continue - if not carrier_cfg.get("enable", False): - continue - gabriel_cfg = carrier_cfg.get("gabriel_filter", {}) - if not isinstance(gabriel_cfg, dict): - continue - if not gabriel_cfg.get("enable", False): - continue - if "min_degree" not in gabriel_cfg: - continue - relevant.append((carrier_cfg, gabriel_cfg)) - - if not relevant: - return False - - gabriel_enabled_values = { - bool(gabriel_cfg.get("enable", True)) for _, gabriel_cfg in relevant - } - if len(gabriel_enabled_values) != 1: - raise ValueError( - "Inconsistent transmission..gabriel_filter.enable across " - "carriers with gabriel_filter.min_degree." - ) - - return gabriel_enabled_values.pop() - - -def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: - """ - Build sorted unique undirected edges from Delaunay simplices. - - Parameters - ---------- - coords_meter : NDArray[np.float64] - Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. - - Returns - ------- - list[tuple[int, int]] - Sorted list of unique undirected edges represented as index pairs - ``(u, v)`` with ``u < v``. - - Raises - ------ - ValueError - If fewer than three points are provided. - """ - if len(coords_meter) < 3: - raise ValueError("Need at least 3 points to compute Delaunay triangulation.") - - try: - tri = Delaunay(coords_meter) - except QhullError: - # Robust fallback for nearly degenerate point sets. - tri = Delaunay(coords_meter, qhull_options="QJ") - - edges: set[tuple[int, int]] = set() - for simplex in tri.simplices: - for i, j in ((0, 1), (1, 2), (0, 2)): - edges.add(tuple(sorted((int(simplex[i]), int(simplex[j]))))) - return sorted(edges) - - -def pairwise_sq_dists(coords: NDArray[np.float64]) -> NDArray[np.float64]: - """ - Compute the squared Euclidean distance matrix. - - Parameters - ---------- - coords : NDArray[np.float64] - Node coordinates in Euclidean space with shape ``(n_nodes, n_dims)``. - - Returns - ------- - NDArray[np.float64] - Dense matrix ``D`` with shape ``(n_nodes, n_nodes)`` where - ``D[i, j] = ||coords[i] - coords[j]||^2``. - """ - norms = np.sum(coords * coords, axis=1) - sq = norms[:, None] + norms[None, :] - 2.0 * (coords @ coords.T) - return np.maximum(sq, 0.0) - - -def classify_gabriel_edges( - edges: NDArray[np.int64], - coords_meter: NDArray[np.float64], - eps: float = 1e-9, - chunk_size: int = 1024, -) -> NDArray[np.bool_]: - """ - Classify candidate edges as Gabriel or non-Gabriel. - - Parameters - ---------- - edges : NDArray[np.int64] - Candidate undirected edges as node index pairs with shape ``(n_edges, 2)``. - coords_meter : NDArray[np.float64] - Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. - eps : float, default 1e-9 - Numerical tolerance used in the Gabriel emptiness test. - chunk_size : int, default 1024 - Batch size used to limit temporary memory during vectorized checks. - - Returns - ------- - NDArray[np.bool_] - Boolean mask of shape ``(n_edges,)`` where ``True`` marks Gabriel edges. - """ - sq_dists = pairwise_sq_dists(coords_meter) - edge_count = edges.shape[0] - is_gabriel = np.ones(edge_count, dtype=bool) - - for start in range(0, edge_count, chunk_size): - end = min(start + chunk_size, edge_count) - uv = edges[start:end] - u = uv[:, 0] - v = uv[:, 1] - dij2 = sq_dists[u, v] - - # A point k invalidates edge (u, v) when dik^2 + djk^2 < dij^2. - inside = (sq_dists[u, :] + sq_dists[v, :]) < (dij2[:, None] - eps) - row = np.arange(end - start) - inside[row, u] = False - inside[row, v] = False - is_gabriel[start:end] = ~inside.any(axis=1) - - return is_gabriel - - -def get_bus_coordinates( - n: pypsa.Network, -) -> tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]]: - """ - Extract bus ids and coordinates in geographic and metric CRS. - - Parameters - ---------- - n : pypsa.Network - Input network containing bus coordinate columns ``x`` and ``y``. - - Returns - ------- - tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]] - Tuple ``(bus_ids, coords_geo, coords_meter)`` where ``coords_geo`` is in - ``EPSG:4326`` and ``coords_meter`` is in ``EPSG:3035``. - - Raises - ------ - ValueError - If fewer than two buses with valid coordinates are available. - """ - buses = n.buses.copy() - buses = buses.dropna(subset=["x", "y"]) - - if len(buses) < 2: - raise ValueError("Need at least 2 buses with coordinates to build corridors.") - - points_geo = gpd.points_from_xy(buses["x"], buses["y"], crs=GEO_CRS) - points_meter = gpd.GeoSeries(points_geo, crs=GEO_CRS).to_crs(DISTANCE_CRS) - - coords_geo = np.column_stack( - (np.asarray(points_geo.x), np.asarray(points_geo.y)) - ).astype(float) - coords_meter = np.column_stack( - (np.asarray(points_meter.x), np.asarray(points_meter.y)) - ).astype(float) - - return buses.index, coords_geo, coords_meter - - -def delaunay_triangulation( - bus_ids: pd.Index, - coords_geo: NDArray[np.float64], - coords_meter: NDArray[np.float64], -) -> gpd.GeoDataFrame: - """ - Build the full Delaunay edge table with geometry and metadata. - - Parameters - ---------- - bus_ids : pd.Index - Bus identifiers aligned with the coordinate arrays. - coords_geo : NDArray[np.float64] - Geographic coordinates (longitude, latitude) in ``EPSG:4326``. - coords_meter : NDArray[np.float64] - Projected coordinates in ``EPSG:3035`` used for metric calculations. - - Returns - ------- - gpd.GeoDataFrame - Delaunay edge table including integer node endpoints, normalized bus - names, canonical edge ``name``, ``length`` (haversine distance, km), - ``gabriel_edge`` flag, and LineString geometry. - """ - - edges = delaunay_edges(coords_meter) - edges_arr = np.array(edges, dtype=np.int64) - u = edges_arr[:, 0] - v = edges_arr[:, 1] - - logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) - lengths = haversine_pts(coords_geo[u], coords_geo[v]) - gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) - edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] - - bus0 = bus_ids.take(u).astype(str).to_numpy() - bus1 = bus_ids.take(v).astype(str).to_numpy() - - # Keep undirected edge naming compatible with topology naming in - # prepare_sector_network.create_network_topology. - swap_mask = bus0 > bus1 - bus0_norm = np.where(swap_mask, bus1, bus0) - bus1_norm = np.where(swap_mask, bus0, bus1) - connector = " -> " - edge_names = np.char.add( - np.char.add(bus0_norm, connector), - bus1_norm, - ) - - edges_gdf = gpd.GeoDataFrame( - { - "source": u, - "target": v, - "bus0": bus0_norm, - "bus1": bus1_norm, - "name": edge_names, - "length": lengths, - "gabriel_edge": gabriel_flags, - "geometry": edge_geoms, - }, - geometry="geometry", - crs=GEO_CRS, - ) - return edges_gdf - - def enforce_min_degree( - selected_edges: gpd.GeoDataFrame, delaunay_graph: gpd.GeoDataFrame, node_count: int, min_degree: int, @@ -341,24 +61,23 @@ def enforce_min_degree( Parameters ---------- - selected_edges : gpd.GeoDataFrame - Currently selected candidate edges, e.g. after Gabriel filtering. - Must contain integer columns ``source``, ``target``, and ``length``. delaunay_graph : gpd.GeoDataFrame - Full Delaunay triangulation edges used as the backfill pool. + Full Delaunay triangulation edge table. Must contain integer columns ``source``, ``target``, and ``length``, - with canonical ordering ``source < target``. + with canonical ordering ``source < target`` and a boolean + ``gabriel_edge`` column. node_count : int Total number of nodes in the graph. Used to size degree arrays. min_degree : int - Minimum number of edges required per node. Values <= 0 disable the - constraint and return ``selected_edges`` unchanged. + Minimum number of edges required per node. Values <= 0 disable + Gabriel filtering and return the full ``delaunay_graph``. Returns ------- gpd.GeoDataFrame - Edge GeoDataFrame with the same schema as ``selected_edges``, - extended with any backfill edges needed to meet the degree constraint. + Edge GeoDataFrame with the same schema as ``delaunay_graph``. + For ``min_degree > 0``, starts from Gabriel edges and extends with any + backfill edges needed to meet the degree constraint. Nodes whose maximum possible Delaunay degree is below ``min_degree`` are handled gracefully with a warning. @@ -369,10 +88,15 @@ def enforce_min_degree( the greedy backfill did not converge (unmet after backfill). """ if min_degree <= 0: - logger.info("Min-degree constraint disabled (min_degree=%s).", min_degree) - return selected_edges + logger.info( + "Gabriel filtering disabled via min_degree=%s; returning full Delaunay graph.", + min_degree, + ) + return delaunay_graph.copy() + + import pandas as pd - result = selected_edges.copy() + result = delaunay_graph[delaunay_graph["gabriel_edge"]].copy() all_sorted = delaunay_graph.sort_values("length").reset_index(drop=True) source_arr = all_sorted["source"].to_numpy(dtype=int) target_arr = all_sorted["target"].to_numpy(dtype=int) @@ -438,85 +162,32 @@ def enforce_min_degree( return result -def prepare_candidate_edges( +def filter_by_max_offshore_haversine_distance( delaunay_graph: gpd.GeoDataFrame, - gabriel_filter_enabled: bool, - node_count: int, - min_degree: int, - max_offshore_haversine_distance_km: float = float("inf"), + max_offdist: float = float("inf"), ) -> gpd.GeoDataFrame: """ - Build selected candidate edges from a Delaunay edge table. + Filter Delaunay edges by maximum offshore haversine distance. Parameters ---------- delaunay_graph : gpd.GeoDataFrame Full Delaunay edge table. - gabriel_filter_enabled : bool - Whether to restrict the initial selection to Gabriel edges. - node_count : int - Number of nodes used for degree accounting. - min_degree : int - Minimum per-node degree target used for optional backfilling. - max_offshore_haversine_distance_km : float, default inf + max_offdist : float, default inf Maximum allowed offshore edge length in km, computed as ``underwater_fraction * length``. ``inf`` disables this filter. Returns ------- gpd.GeoDataFrame - Selected edge table after optional Gabriel filtering and min-degree - backfilling. + Delaunay edge table after applying offshore-distance filtering. """ - if np.isfinite(max_offshore_haversine_distance_km): + if np.isfinite(max_offdist): offshore_length = ( delaunay_graph["length"] * delaunay_graph["underwater_fraction"] ) - candidate_pool = delaunay_graph[ - offshore_length <= max_offshore_haversine_distance_km - ].copy() - else: - candidate_pool = delaunay_graph.copy() - - if not gabriel_filter_enabled: - return candidate_pool - - selected_edges = candidate_pool[candidate_pool["gabriel_edge"]].copy() - selected = enforce_min_degree( - selected_edges=selected_edges, - delaunay_graph=candidate_pool, - node_count=node_count, - min_degree=min_degree, - ) - - return selected - - -def add_underwater_fraction( - edges: gpd.GeoDataFrame, - offshore_shapes: gpd.GeoDataFrame, -) -> gpd.GeoDataFrame: - """ - Compute the fraction of each edge's length that lies within offshore regions. - - Parameters - ---------- - edges : gpd.GeoDataFrame - Edge table with LineString geometry in ``EPSG:4326``. - offshore_shapes : gpd.GeoDataFrame - Offshore region polygons used to determine submarine sections. - - Returns - ------- - gpd.GeoDataFrame - ``edges`` with an added ``underwater_fraction`` column in ``[0, 1]``. - """ - offshore_union = offshore_shapes.to_crs(DISTANCE_CRS).union_all() - geoms = edges.geometry.to_crs(DISTANCE_CRS) - edges["underwater_fraction"] = ( - (geoms.intersection(offshore_union).length / geoms.length).fillna(0.0).round(2) - ) - return edges + return delaunay_graph[offshore_length <= max_offdist].copy() + return delaunay_graph.copy() if __name__ == "__main__": @@ -528,56 +199,38 @@ def add_underwater_fraction( snakemake = mock_snakemake( "build_transmission_topology", clusters="50", + min_degree=1, + max_offdist="inf", + configfiles=["config/config.200.yaml"], ) configure_logging(snakemake) set_scenario_config(snakemake) - transmission_cfg = dict(snakemake.params["transmission"]) - candidate_specs = [ - { - "min_degree": int(spec["min_degree"]), - "max_offshore_haversine_distance_km": float( - spec["max_offshore_haversine_distance_km"] - ), - } - for spec in snakemake.params["candidate_specs"] - ] - - gabriel_filter_enabled = bool(candidate_specs) - - n = pypsa.Network(snakemake.input.network) - offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) - - # Delaunay triangulation and Gabriel filtering - bus_ids, coords_geo, coords_meter = get_bus_coordinates(n) - delaunay_graph = delaunay_triangulation( - bus_ids=bus_ids, - coords_geo=coords_geo, - coords_meter=coords_meter, - ) + min_degree = snakemake.params["min_degree"] + max_offdist = snakemake.params["max_offdist"] - add_underwater_fraction(delaunay_graph, offshore_shapes) + delaunay_graph = gpd.read_file(snakemake.input.delaunay_graph) - # Export - delaunay_graph.to_file(snakemake.output.all_edges) + source_arr = delaunay_graph["source"].to_numpy(dtype=int) + target_arr = delaunay_graph["target"].to_numpy(dtype=int) + node_count = int(max(source_arr.max(), target_arr.max())) + 1 - for spec, output_path in zip( - candidate_specs, snakemake.output.candidates, strict=True - ): - min_degree = spec["min_degree"] - max_offshore_haversine_distance_km = spec["max_offshore_haversine_distance_km"] + logger.info( + "Filtering topology: min_degree=%s, max_offdist=%s.", + min_degree, + max_offdist, + ) - logger.info( - "Preparing candidate edges for min_degree=%s and max_offshore_haversine_distance_km=%s.", - min_degree, - max_offshore_haversine_distance_km, - ) - selected_edges = prepare_candidate_edges( - delaunay_graph=delaunay_graph, - gabriel_filter_enabled=gabriel_filter_enabled, - node_count=len(coords_geo), - min_degree=min_degree, - max_offshore_haversine_distance_km=max_offshore_haversine_distance_km, - )[COLS_EDGES] - selected_edges.to_file(output_path) + candidate_pool = filter_by_max_offshore_haversine_distance( + delaunay_graph=delaunay_graph, + max_offdist=max_offdist, + ) + + selected_edges = enforce_min_degree( + delaunay_graph=candidate_pool, + node_count=node_count, + min_degree=min_degree, + )[COLS_EDGES] + + selected_edges.to_file(snakemake.output.candidates) diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py index 0c06bcf68d..5b1b8dee39 100644 --- a/scripts/lib/validation/config/transmission.py +++ b/scripts/lib/validation/config/transmission.py @@ -199,20 +199,6 @@ class ElectricityProjectsConfig(BaseModel): ) -class _GabrielFilterConfig(BaseModel): - """Configuration for `transmission..gabriel_filter` settings.""" - - enable: bool = Field( - True, - description="Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - ) - min_degree: int = Field( - 1, - ge=0, - description="Minimum node degree target applied after Gabriel filtering.", - ) - - class _TransmissionCarrierConfigGeneral(BaseModel): """Configuration for a single transmission carrier.""" @@ -220,9 +206,10 @@ class _TransmissionCarrierConfigGeneral(BaseModel): True, description="Enable transmission candidate generation for this carrier.", ) - gabriel_filter: _GabrielFilterConfig = Field( - default_factory=_GabrielFilterConfig, - description="Gabriel filter configuration.", + gabriel_filter_min_degree: int = Field( + 1, + ge=0, + description="Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", ) max_offshore_haversine_distance: float = Field( float("inf"), diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d02e797ad9..b832e39e5f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2118,12 +2118,11 @@ def add_h2_gas_infrastructure( p_min_pu=-1, p_nom_extendable=True, length=( - h2_pipes["length"].values - * cf_transmission["hydrogen"].get("length_factor", 1.0) + h2_pipes["length"].values * cf_transmission["hydrogen"]["length_factor"] ), capital_cost=costs.at["H2 (g) pipeline", "capital_cost"] * h2_pipes["length"].values - * cf_transmission["hydrogen"].get("length_factor", 1.0) + * cf_transmission["hydrogen"]["length_factor"] * cf_transmission["hydrogen"]["cost_factor"], carrier="H2 pipeline", lifetime=costs.at["H2 (g) pipeline", "lifetime"], @@ -6556,9 +6555,9 @@ def add_import_options( co2_transmission_cost_factor=cf_transmission["carbon_dioxide"][ "cost_factor" ], - co2_transmission_length_factor=cf_transmission["carbon_dioxide"].get( - "length_factor", 1.0 - ), + co2_transmission_length_factor=cf_transmission["carbon_dioxide"][ + "length_factor" + ], ) if options["allam_cycle_gas"]: From 00d1c7d1159e278417f402be68d551fa60d2a81b Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Mon, 27 Apr 2026 15:22:25 +0200 Subject: [PATCH 28/32] Updated schema. --- config/schema.default.json | 136 ++++++++----------------------------- 1 file changed, 30 insertions(+), 106 deletions(-) diff --git a/config/schema.default.json b/config/schema.default.json index de89d6bd45..2c9cdde247 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5895,21 +5895,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, @@ -5976,21 +5966,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, @@ -7028,22 +7008,6 @@ } } }, - "_GabrielFilterConfig": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } - }, "_HydroConfig": { "description": "Configuration for hydropower.", "properties": { @@ -8862,21 +8826,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, @@ -8905,21 +8859,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, @@ -9958,21 +9902,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, @@ -10039,21 +9973,11 @@ "description": "Enable transmission candidate generation for this carrier.", "type": "boolean" }, - "gabriel_filter": { - "description": "Configuration for `transmission..gabriel_filter` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to filter Delaunay edges to Gabriel edges before min-degree backfilling.", - "type": "boolean" - }, - "min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel filtering.", - "minimum": 0, - "type": "integer" - } - } + "gabriel_filter_min_degree": { + "default": 1, + "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", + "minimum": 0, + "type": "integer" }, "max_offshore_haversine_distance": { "default": null, From 1768e73c58eb2f02e42c468af406afc7bb945527 Mon Sep 17 00:00:00 2001 From: Fabian Neumann Date: Tue, 6 Oct 2026 11:20:34 +0200 Subject: [PATCH 29/32] Reduce to topology feature without breaking config changes Keep the Delaunay/Gabriel candidate topology for new H2 and CO2 pipelines, configured by the additive key sector: pipeline_topology. Revert the restructure of lines, links, transformers, transmission_projects and the sector network keys into a new transmission section. --- Snakefile | 4 +- config/config.default.yaml | 191 +- ...config.distribution-grid-experimental.yaml | 9 +- config/examples/config.entsoe-all.yaml | 6 +- config/examples/config.iterative.yaml | 13 +- config/examples/config.osm-release.yaml | 9 +- config/examples/config.validation.yaml | 13 +- config/schema.default.json | 3307 ++++++----------- config/test/config.electricity.yaml | 10 +- config/test/config.myopic.yaml | 5 +- config/test/config.overnight.yaml | 12 +- config/test/config.perfect.yaml | 5 +- config/test/config.tyndp.yaml | 32 +- doc/configuration.rst | 114 +- doc/preparation.rst | 8 +- doc/release_notes.rst | 20 - doc/tutorial.rst | 64 +- doc/tutorial_sector.rst | 12 +- rules/build_electricity.smk | 117 +- rules/build_sector.smk | 55 +- rules/development.smk | 12 +- rules/postprocess.smk | 4 +- rules/solve_myopic.smk | 7 +- rules/solve_overnight.smk | 1 - rules/solve_perfect.smk | 2 - scripts/_helpers.py | 18 +- scripts/add_brownfield.py | 20 +- scripts/add_electricity.py | 5 +- scripts/add_existing_baseyear.py | 11 +- ...y => add_transmission_projects_and_dlr.py} | 2 +- scripts/base_network.py | 46 +- scripts/build_transmission_delaunay_graph.py | 11 +- ...ects.py => build_transmission_projects.py} | 12 +- scripts/build_transmission_topology.py | 21 +- scripts/lib/validation/config/_schema.py | 27 +- scripts/lib/validation/config/electricity.py | 10 + scripts/lib/validation/config/lines.py | 91 + scripts/lib/validation/config/links.py | 44 + scripts/lib/validation/config/sector.py | 113 +- scripts/lib/validation/config/transformers.py | 30 + scripts/lib/validation/config/transmission.py | 375 -- .../config/transmission_projects.py | 57 + scripts/prepare_sector_network.py | 249 +- scripts/simplify_network.py | 4 +- scripts/solve_network.py | 4 +- test/test_base_network.py | 6 +- 46 files changed, 2055 insertions(+), 3133 deletions(-) rename scripts/{add_electricity_transmission_projects_and_dlr.py => add_transmission_projects_and_dlr.py} (97%) rename scripts/{build_electricity_transmission_projects.py => build_transmission_projects.py} (96%) create mode 100644 scripts/lib/validation/config/lines.py create mode 100644 scripts/lib/validation/config/links.py create mode 100644 scripts/lib/validation/config/transformers.py delete mode 100644 scripts/lib/validation/config/transmission.py create mode 100644 scripts/lib/validation/config/transmission_projects.py diff --git a/Snakefile b/Snakefile index ec6d92a7a4..5bca217a60 100644 --- a/Snakefile +++ b/Snakefile @@ -129,7 +129,7 @@ rule all: ( RESULTS + "maps/static/base_s_{clusters}_{opts}_{sector_opts}-h2_network_{planning_horizons}.pdf" - if config_provider("transmission", "hydrogen", "enable")(w) + if config_provider("sector", "H2_network")(w) else [] ), run=config["run"]["name"], @@ -139,7 +139,7 @@ rule all: ( RESULTS + "maps/static/base_s_{clusters}_{opts}_{sector_opts}-ch4_network_{planning_horizons}.pdf" - if config_provider("transmission", "gas", "enable")(w) + if config_provider("sector", "gas_network")(w) else [] ), run=config["run"]["name"], diff --git a/config/config.default.yaml b/config/config.default.yaml index e303c53379..6c98c2d215 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -112,6 +112,7 @@ electricity: - 400.0 - 500.0 - 750.0 + base_network: osm gaslimit_enable: false gaslimit: false co2limit_enable: false @@ -181,101 +182,7 @@ electricity: autarky: enable: false by_country: false - -# 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_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_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 + transmission_limit: vopt # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#atlite atlite: @@ -520,6 +427,66 @@ conventional: nuclear: p_max_pu: data/nuclear_p_max_pu.csv +# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#lines +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 + +# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#links +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 + +# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission_projects +transmission_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 + +# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transformers +transformers: + x: 0.1 + s_nom: 2000.0 + type: "" + # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#load load: fill_gaps: @@ -903,6 +870,8 @@ sector: co2_sequestration_cost: 30 co2_sequestration_lifetime: 50 co2_spatial: true + co2_network: true + co2_network_cost_factor: 1 cc_fraction: 0.9 hydrogen_underground_storage: true hydrogen_underground_storage_locations: @@ -931,8 +900,36 @@ sector: use_methanation_waste_heat: 0.25 use_fuel_cell_waste_heat: 1 use_electrolysis_waste_heat: 0.25 - electricity_grid_connection_cost: true - gas_distribution_grid_cost: true + electricity_transmission_grid: true + electricity_distribution_grid: true + electricity_distribution_grid_cost_factor: 1.0 + electricity_grid_connection: true + transmission_efficiency: + enable: + - DC + - H2 pipeline + - gas pipeline + - electricity distribution grid + DC: + efficiency_static: 0.98 + efficiency_per_1000km: 0.977 + H2 pipeline: + efficiency_per_1000km: 1 + compression_per_1000km: 0.018 + gas pipeline: + efficiency_per_1000km: 1 + compression_per_1000km: 0.01 + electricity distribution grid: + efficiency_static: 0.97 + H2_network: true + pipeline_topology: + min_degree: 1 + max_offshore_distance: .inf + gas_network: true + H2_retrofit: false + H2_retrofit_capacity_per_CH4: 0.6 + gas_network_connectivity_upgrade: 1 + gas_distribution_grid: true gas_distribution_grid_cost_factor: 1.0 biomass_spatial: true biomass_transport: false diff --git a/config/examples/config.distribution-grid-experimental.yaml b/config/examples/config.distribution-grid-experimental.yaml index 3399f86093..06642fe5ae 100644 --- a/config/examples/config.distribution-grid-experimental.yaml +++ b/config/examples/config.distribution-grid-experimental.yaml @@ -45,13 +45,10 @@ countries: electricity: voltages: [63., 66., 90., 110., 132., 150., 220., 300., 330., 380., 400., 500., 750.] + base_network: osm -transmission: - electricity: - enable: true - base_network: osm - projects: - enable: false +transmission_projects: + enable: false data: osm: diff --git a/config/examples/config.entsoe-all.yaml b/config/examples/config.entsoe-all.yaml index 2ca21bf5a9..6f5627e6ee 100644 --- a/config/examples/config.entsoe-all.yaml +++ b/config/examples/config.entsoe-all.yaml @@ -31,7 +31,5 @@ electricity: co2limit: 9.59e+7 co2base: 1.918e+9 -transmission: - electricity: - lines: - reconnect_crimea: true +lines: + reconnect_crimea: true diff --git a/config/examples/config.iterative.yaml b/config/examples/config.iterative.yaml index 4a5a2a5a9e..18076927e2 100644 --- a/config/examples/config.iterative.yaml +++ b/config/examples/config.iterative.yaml @@ -28,6 +28,7 @@ snapshots: end: "2013-03-08" electricity: + extendable_carriers: Generator: [OCGT] StorageUnit: [battery] @@ -36,15 +37,6 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] -transmission: - hydrogen: - retrofit: - enable: true - carbon_dioxide: - enable: false - gas: - enable: true - atlite: default_cutout: be-03-2013-era5 cutouts: @@ -68,6 +60,8 @@ clustering: resolution_sector: 24h sector: + gas_network: true + H2_retrofit: true district_heating: ptes: supplemental_heating: @@ -91,6 +85,7 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false + co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/config/examples/config.osm-release.yaml b/config/examples/config.osm-release.yaml index 2a7ff4699b..47461fde41 100644 --- a/config/examples/config.osm-release.yaml +++ b/config/examples/config.osm-release.yaml @@ -52,13 +52,10 @@ electricity: - 400.0 - 500.0 - 750.0 + base_network: osm -transmission: - electricity: - enable: true - base_network: osm - projects: - enable: false +transmission_projects: + enable: false data: osm: diff --git a/config/examples/config.validation.yaml b/config/examples/config.validation.yaml index 2c45fa4954..d48b5295b4 100644 --- a/config/examples/config.validation.yaml +++ b/config/examples/config.validation.yaml @@ -61,13 +61,12 @@ conventional: biomass: p_max_pu: 0.65 -transmission: - electricity: - lines: - s_max_pu: 0.23 - under_construction: 'remove' - projects: - enable: false +lines: + s_max_pu: 0.23 + under_construction: 'remove' + +links: + include_tyndp: false costs: year: 2020 diff --git a/config/schema.default.json b/config/schema.default.json index 2c9cdde247..d54abe01c1 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -2063,6 +2063,16 @@ }, "type": "array" }, + "base_network": { + "default": "osm", + "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + "enum": [ + "entsoegridkit", + "osm", + "tyndp" + ], + "type": "string" + }, "gaslimit_enable": { "default": false, "description": "Add an overall absolute gas limit configured in `electricity: gaslimit`.", @@ -2327,71 +2337,10 @@ "type": "boolean" } } - } - } - }, - "ElectricityProjectsConfig": { - "description": "Configuration for `electricity.projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `electricity.projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } - }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", - "enum": [ - "zero", - "keep" - ], + "transmission_limit": { + "default": "vopt", + "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", "type": "string" } } @@ -2818,26 +2767,6 @@ "keep" ], "type": "string" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity.links.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for DC links.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.98, - "description": "Static DC transmission efficiency.", - "type": "number" - }, - "efficiency_per_1000km": { - "default": 0.977, - "description": "Distance-dependent DC efficiency factor per 1000 km.", - "type": "number" - } - } } } }, @@ -4769,6 +4698,16 @@ "description": "Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", "type": "boolean" }, + "co2_network": { + "default": true, + "description": "Add option for planning a new carbon dioxide transmission network.", + "type": "boolean" + }, + "co2_network_cost_factor": { + "default": 1, + "description": "The cost factor for the capital cost of the carbon dioxide transmission network.", + "type": "number" + }, "cc_fraction": { "default": 0.9, "description": "The default fraction of CO2 captured with post-combustion capture.", @@ -4890,14 +4829,114 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_grid_connection_cost": { + "electricity_transmission_grid": { + "default": true, + "description": "Switch for enabling/disabling the electricity transmission grid.", + "type": "boolean" + }, + "electricity_distribution_grid": { + "default": true, + "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "type": "boolean" + }, + "electricity_distribution_grid_cost_factor": { + "default": 1.0, + "description": "Multiplies the investment cost of the electricity distribution grid.", + "type": "number" + }, + "electricity_grid_connection": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "gas_distribution_grid_cost": { + "transmission_efficiency": { + "description": "Configuration for `sector.transmission_efficiency` settings.", + "properties": { + "enable": { + "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", + "items": { + "type": "string" + }, + "type": "array" + }, + "DC": { + "additionalProperties": { + "type": "number" + }, + "description": "DC transmission efficiency.", + "type": "object" + }, + "H2 pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "H2 pipeline transmission efficiency.", + "type": "object" + }, + "gas pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "Gas pipeline transmission efficiency.", + "type": "object" + }, + "electricity distribution grid": { + "additionalProperties": { + "type": "number" + }, + "description": "Electricity distribution grid efficiency.", + "type": "object" + } + } + }, + "H2_network": { + "default": true, + "description": "Add option for new hydrogen pipelines.", + "type": "boolean" + }, + "pipeline_topology": { + "description": "Configuration for `sector.pipeline_topology` settings.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate pipeline (km).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "gas_network": { + "default": true, + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "type": "boolean" + }, + "H2_retrofit": { + "default": false, + "description": "Add option for retrofiting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "H2_retrofit_capacity_per_CH4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + }, + "gas_network_connectivity_upgrade": { + "default": 1, + "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", + "type": "number" + }, + "gas_distribution_grid": { "default": true, - "description": "Add gas distribution grid costs to gas-consuming components.", + "description": "Add a gas distribution grid.", "type": "boolean" }, "gas_distribution_grid_cost_factor": { @@ -5581,479 +5620,95 @@ } } }, - "TransmissionConfig": { - "description": "Configuration for `transmission` settings.", + "TransmissionProjectsConfig": { + "description": "Configuration for `transmission_projects` settings.", "properties": { - "electricity": { - "description": "Configuration for electricity transmission grid.", + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", + "type": "boolean" + }, + "include": { + "description": "Configuration for `transmission_projects.include` settings.", "properties": { - "enable": { + "tyndp2020": { "default": true, - "description": "Switch for enabling/disabling the electricity transmission grid.", + "description": "Whether to integrate the TYNDP 2020 dataset.", "type": "boolean" }, - "base_network": { - "default": "osm", - "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - "enum": [ - "entsoegridkit", - "osm", - "tyndp" - ], - "type": "string" + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" }, - "transmission_limit": { - "default": "vopt", - "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - "type": "string" + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } + }, + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "lines": { - "description": "Configuration for `lines` settings.", - "properties": { - "types": { + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } + }, + "_AdjustmentConfig": { + "description": "Configuration for adjustment settings (factor/absolute)", + "properties": { + "factor": { + "anyOf": [ + { + "type": "boolean" + }, + { + "additionalProperties": { + "additionalProperties": { "additionalProperties": { - "type": "string" - }, - "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", - "type": "object" - }, - "s_max_pu": { - "default": 0.7, - "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - "type": "number" - }, - "s_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable line (MW).", - "type": "number" - }, - "max_extension": { - "default": 20000, - "description": "Upper limit for the extended capacity of each extendable line (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - "type": "number" - }, - "reconnect_crimea": { - "default": true, - "description": "Whether to reconnect Crimea to the Ukrainian grid.", - "type": "boolean" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - }, - "dynamic_line_rating": { - "description": "Configuration for `lines.dynamic_line_rating` settings.", - "properties": { - "activate": { - "default": false, - "description": "Whether to take dynamic line rating into account.", - "type": "boolean" - }, - "cutout": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "default", - "description": "Specifies the weather data cutout file(s) to use." - }, - "correction_factor": { - "default": 0.95, - "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - "type": "number" - }, - "max_voltage_difference": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum voltage angle difference in degrees or 'false' to disable." - }, - "max_line_rating": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." - } - } - } - } - }, - "links": { - "description": "Configuration for `links` settings.", - "properties": { - "p_max_pu": { - "default": 1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_min_pu": { - "default": -1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", - "type": "number" - }, - "max_extension": { - "default": 30000, - "description": "Upper limit for the extended capacity of each extendable DC link (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - "type": "number" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity.links.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for DC links.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.98, - "description": "Static DC transmission efficiency.", - "type": "number" - }, - "efficiency_per_1000km": { - "default": 0.977, - "description": "Distance-dependent DC efficiency factor per 1000 km.", - "type": "number" - } - } - } - } - }, - "transformers": { - "description": "Configuration for `transformers` settings.", - "properties": { - "x": { - "default": 0.1, - "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - "type": "number" - }, - "s_nom": { - "default": 2000.0, - "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - "type": "number" - }, - "type": { - "default": "", - "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", - "type": "string" - } - } - }, - "projects": { - "description": "Configuration for `electricity.projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `electricity.projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } - }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." - }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", - "enum": [ - "zero", - "keep" - ], - "type": "string" - } - } - } - } - }, - "electricity_distribution": { - "properties": { - "enable": { - "default": true, - "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - "type": "boolean" - }, - "cost_factor": { - "default": 1.0, - "description": "Multiplies the investment cost of the electricity distribution grid.", - "type": "number" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply electricity distribution efficiency.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.97, - "description": "Static electricity distribution efficiency.", - "type": "number" - } - } - } - }, - "description": "Configuration for simplified electricity distribution grid." - }, - "hydrogen": { - "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", - "type": "boolean" - }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" - }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, - "type": "number" - }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, - "type": "number" - }, - "retrofit": { - "description": "Configuration for `transmission.hydrogen.retrofit` settings.", - "properties": { - "enable": { - "default": false, - "description": "Add option for retrofitting existing pipelines to transport hydrogen.", - "type": "boolean" - }, - "capacity_per_ch4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "type": "number" - } - } - }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } - } - }, - "description": "Configuration for hydrogen transmission candidates." - }, - "carbon_dioxide": { - "description": "Configuration for a single transmission carrier.", - "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", - "type": "boolean" - }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" - }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, - "type": "number" - }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, - "type": "number" - } - } - }, - "gas": { - "properties": { - "enable": { - "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "type": "boolean" - }, - "connectivity_upgrade": { - "default": 1, - "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", - "type": "number" - }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } - } - }, - "description": "Configuration for methane gas transmission candidates." - } - } - }, - "_AdjustmentConfig": { - "description": "Configuration for adjustment settings (factor/absolute)", - "properties": { - "factor": { - "anyOf": [ - { - "type": "boolean" - }, - { - "additionalProperties": { - "additionalProperties": { - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "additionalProperties": { - "type": "number" - }, - "type": "object" - } - ] + "anyOf": [ + { + "type": "number" + }, + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + } + ] }, "type": "object" }, @@ -6569,26 +6224,6 @@ } } }, - "_DCEfficiencyConfig": { - "description": "Configuration for `transmission.electricity.links.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for DC links.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.98, - "description": "Static DC transmission efficiency.", - "type": "number" - }, - "efficiency_per_1000km": { - "default": 0.977, - "description": "Distance-dependent DC efficiency factor per 1000 km.", - "type": "number" - } - } - }, "_DataSourceConfig": { "description": "Configuration for a single data source.", "properties": { @@ -6762,23 +6397,8 @@ } } }, - "_ElectricityDistributionEfficiencyConfig": { - "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply electricity distribution efficiency.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.97, - "description": "Static electricity distribution efficiency.", - "type": "number" - } - } - }, - "_EmissionPricesConfig": { - "description": "Configuration for `costs.emission_prices` settings.", + "_EmissionPricesConfig": { + "description": "Configuration for `costs.emission_prices` settings.", "properties": { "enable": { "default": false, @@ -7091,22 +6711,6 @@ } } }, - "_HydrogenRetrofitConfig": { - "description": "Configuration for `transmission.hydrogen.retrofit` settings.", - "properties": { - "enable": { - "default": false, - "description": "Add option for retrofitting existing pipelines to transport hydrogen.", - "type": "boolean" - }, - "capacity_per_ch4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "type": "number" - } - } - }, "_ImportsConfig": { "description": "Configuration for `sector.imports` settings.", "properties": { @@ -7135,7 +6739,7 @@ } }, "_IncludeConfig": { - "description": "Configuration for `electricity.projects.include` settings.", + "description": "Configuration for `transmission_projects.include` settings.", "properties": { "tyndp2020": { "default": true, @@ -7742,6 +7346,23 @@ } } }, + "_PipelineTopologyConfig": { + "description": "Configuration for `sector.pipeline_topology` settings.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate pipeline (km).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, "_PostDiscretizationConfig": { "description": "Configuration for `solving.options.post_discretization` settings.", "properties": { @@ -8479,647 +8100,572 @@ } } }, - "_TransmissionCarrierConfigElectricity": { - "description": "Configuration for electricity transmission grid.", + "_TransmissionEfficiencyConfig": { + "description": "Configuration for `sector.transmission_efficiency` settings.", "properties": { "enable": { - "default": true, - "description": "Switch for enabling/disabling the electricity transmission grid.", - "type": "boolean" + "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", + "items": { + "type": "string" + }, + "type": "array" }, - "base_network": { - "default": "osm", - "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - "enum": [ - "entsoegridkit", - "osm", - "tyndp" - ], + "DC": { + "additionalProperties": { + "type": "number" + }, + "description": "DC transmission efficiency.", + "type": "object" + }, + "H2 pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "H2 pipeline transmission efficiency.", + "type": "object" + }, + "gas pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "Gas pipeline transmission efficiency.", + "type": "object" + }, + "electricity distribution grid": { + "additionalProperties": { + "type": "number" + }, + "description": "Electricity distribution grid efficiency.", + "type": "object" + } + } + }, + "_UserAgentConfig": { + "description": "Configuration for `overpass_api.user_agent` settings.", + "properties": { + "project_name": { + "default": "PyPSA-Eur", + "description": "Project name used to identify the user agent of the Overpass API requests.", "type": "string" }, - "transmission_limit": { - "default": "vopt", - "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + "email": { + "default": "contact@pypsa.org", + "description": "Contact email address for the project using the Overpass API.", "type": "string" }, - "lines": { - "description": "Configuration for `lines` settings.", - "properties": { - "types": { + "website": { + "default": "https://github.com/PyPSA/pypsa-eur", + "description": "Website URL for the project using the Overpass API.", + "type": "string" + } + } + }, + "_WindResourceConfig": { + "description": "Configuration for wind resource settings.", + "properties": { + "method": { + "default": "wind", + "description": "A superordinate technology type.", + "type": "string" + }, + "turbine": { + "anyOf": [ + { + "type": "string" + }, + { "additionalProperties": { "type": "string" }, - "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", "type": "object" + } + ], + "description": "Specifies the turbine type and its characteristic power curve. Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available." + }, + "smooth": { + "default": false, + "description": "Switch to apply a gaussian kernel density smoothing to the power curve.", + "type": "boolean" + }, + "add_cutout_windspeed": { + "default": true, + "description": "Whether to add cutout windspeed data.", + "type": "boolean" + } + }, + "required": [ + "turbine" + ] + } + }, + "additionalProperties": true, + "description": "Combined configuration schema for PyPSA-EUR.", + "properties": { + "version": { + "default": "v2026.02.0", + "description": "Version of PyPSA-Eur. Descriptive only.", + "type": "string" + }, + "tutorial": { + "default": false, + "description": "Switch to retrieve the tutorial data set instead of the full data set.", + "type": "boolean" + }, + "logging": { + "description": "Configuration for top level `logging` settings.", + "properties": { + "level": { + "default": "INFO", + "description": "Restrict console outputs to all infos, warning or errors only", + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "type": "string" + }, + "format": { + "default": "%(levelname)s:%(name)s:%(message)s", + "description": "Custom format for log messages. See `LogRecord `_ attributes.", + "markdownDescription": "Custom format for log messages. See [LogRecord ](https://docs.python.org/3/library/logging.html#logging.LogRecord) attributes.", + "type": "string" + } + } + }, + "remote": { + "description": "Configuration for top level `remote` settings.", + "properties": { + "ssh": { + "default": "", + "description": "Optionally specify the SSH of a remote cluster to be synchronized.", + "type": "string" + }, + "path": { + "default": "", + "description": "Optionally specify the file path within the remote cluster to be synchronized.", + "type": "string" + } + } + }, + "run": { + "description": "Configuration for top level `run` settings.", + "properties": { + "prefix": { + "default": "", + "description": "Prefix for the run name which is used as a top-layer directory name in the results and resources folders.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" }, - "s_max_pu": { - "default": 0.7, - "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - "type": "number" - }, - "s_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable line (MW).", - "type": "number" - }, - "max_extension": { - "default": 20000, - "description": "Upper limit for the extended capacity of each extendable line (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - "type": "number" - }, - "reconnect_crimea": { - "default": true, - "description": "Whether to reconnect Crimea to the Ukrainian grid.", + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "", + "description": "Specify a name for your run. Results will be stored under this name. If ``scenario: enable:`` is set to ``true``, the name must contain a subset of scenario names defined in ``scenario: file:``. If the name is 'all', all defined scenarios will be run.", + "examples": [ + "my-pypsa-eur-run" + ] + }, + "scenarios": { + "description": "Configuration for `run.scenarios` level.", + "properties": { + "enable": { + "default": false, + "description": "Switch to select whether workflow should generate scenarios based on ``file``.", "type": "boolean" }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" + "file": { + "default": "config/scenarios.yaml", + "description": "Path to the scenario yaml file. The scenario file contains config overrides for each scenario. In order to be taken account, ``run: scenarios`` has to be set to ``true`` and ``run: name`` has to be a subset of top level keys given in the scenario file. In order to automatically create a `scenario.yaml` file based on a combination of settings, alter and use the ``config/create_scenarios.py`` script in the ``config`` directory.", + "examples": [ + "config/scenarios.yaml" ], "type": "string" - }, - "dynamic_line_rating": { - "description": "Configuration for `lines.dynamic_line_rating` settings.", - "properties": { - "activate": { - "default": false, - "description": "Whether to take dynamic line rating into account.", + } + } + }, + "disable_progressbar": { + "default": false, + "description": "Switch to select whether progressbar should be disabled.", + "type": "boolean" + }, + "shared_resources": { + "description": "Configuration for `run.shared_resources` level.", + "properties": { + "policy": { + "anyOf": [ + { "type": "boolean" }, - "cutout": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "default", - "description": "Specifies the weather data cutout file(s) to use." - }, - "correction_factor": { - "default": 0.95, - "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - "type": "number" - }, - "max_voltage_difference": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum voltage angle difference in degrees or 'false' to disable." - }, - "max_line_rating": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." + { + "type": "string" } - } - } - } - }, - "links": { - "description": "Configuration for `links` settings.", - "properties": { - "p_max_pu": { - "default": 1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_min_pu": { - "default": -1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", - "type": "number" - }, - "max_extension": { - "default": 30000, - "description": "Upper limit for the extended capacity of each extendable DC link (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - "type": "number" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" ], - "type": "string" + "default": false, + "description": "Boolean switch to select whether resources should be shared across runs. If a string is passed, this is used as a subdirectory name for shared resources. If set to 'base', only resources before creating the elec.nc file are shared.", + "examples": [ + false, + "base" + ] }, - "efficiency": { - "description": "Configuration for `transmission.electricity.links.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for DC links.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.98, - "description": "Static DC transmission efficiency.", - "type": "number" - }, - "efficiency_per_1000km": { - "default": 0.977, - "description": "Distance-dependent DC efficiency factor per 1000 km.", - "type": "number" - } - } + "exclude": { + "description": "For the case shared_resources=base, specify additional files that should not be shared across runs.", + "items": { + "type": "string" + }, + "type": "array" } } }, - "transformers": { - "description": "Configuration for `transformers` settings.", - "properties": { - "x": { - "default": 0.1, - "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - "type": "number" - }, - "s_nom": { - "default": 2000.0, - "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - "type": "number" - }, - "type": { - "default": "", - "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + "use_shadow_directory": { + "default": false, + "description": "Set to ``true`` (default) if snakemake shadow directories (``shallow``) should be used. Set to ``false`` if problems occur.", + "examples": [ + true + ], + "type": "boolean" + } + } + }, + "foresight": { + "default": "overnight", + "description": "Configuration for `foresight` settings.", + "enum": [ + "overnight", + "myopic", + "perfect" + ], + "type": "string" + }, + "scenario": { + "description": "Configuration for top level `scenario` settings.", + "properties": { + "clusters": { + "description": "List of ``{clusters}`` wildcards to run. Use 'adm' for administrative clustering mode, 'all' for all nodes.", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "enum": [ + "adm", + "all" + ], + "type": "string" + } + ] + }, + "type": "array" + }, + "opts": { + "description": "List of ``{opts}`` wildcards to run.", + "items": { + "type": "string" + }, + "type": "array" + }, + "sector_opts": { + "description": "List of ``{sector_opts}`` wildcards to run.", + "items": { + "type": "string" + }, + "type": "array" + }, + "planning_horizons": { + "description": "List of ``{planning_horizon}`` wildcards to run.", + "items": { + "type": "integer" + }, + "type": "array" + } + } + }, + "countries": { + "default": [ + "AL", + "AT", + "BA", + "BE", + "BG", + "CH", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FR", + "GB", + "GR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LU", + "LV", + "ME", + "MK", + "NL", + "NO", + "PL", + "PT", + "RO", + "RS", + "SE", + "SI", + "SK", + "XK" + ], + "description": "Configuration for `countries` settings.", + "items": { + "type": "string" + }, + "type": "array" + }, + "snapshots": { + "description": "Configuration for `snapshots` settings.", + "properties": { + "start": { + "anyOf": [ + { "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" } - } + ], + "default": "2013-01-01", + "description": "Left bound of date range." }, - "projects": { - "description": "Configuration for `electricity.projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `electricity.projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } + "end": { + "anyOf": [ + { + "type": "string" }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + { "items": { "type": "string" }, "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." - }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + } + ], + "default": "2014-01-01", + "description": "Right bound of date range." + }, + "inclusive": { + "anyOf": [ + { "enum": [ - "zero", - "keep" + "left", + "right", + "both" ], "type": "string" + }, + { + "type": "null" } - } + ], + "default": "left", + "description": "Make the time interval closed to the `left`, `right`, or both sides `both` or neither side `None`." } } }, - "_TransmissionCarrierConfigElectricityDistribution": { + "enable": { + "description": "Configuration for `enable` settings.", "properties": { - "enable": { + "drop_leap_day": { "default": true, - "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "description": "Switch to drop February 29 from all time-dependent data in leap years.", "type": "boolean" - }, - "cost_factor": { - "default": 1.0, - "description": "Multiplies the investment cost of the electricity distribution grid.", - "type": "number" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply electricity distribution efficiency.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.97, - "description": "Static electricity distribution efficiency.", - "type": "number" - } - } } } }, - "_TransmissionCarrierConfigGas": { - "properties": { - "enable": { - "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "type": "boolean" - }, - "connectivity_upgrade": { - "default": 1, - "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", - "type": "number" + "co2_budget": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "description": "Configuration for `co2_budget` settings.", + "type": "object" }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } + { + "type": "null" } - } + ], + "description": "CO2 budget as fraction of 1990 emissions per planning horizon year." }, - "_TransmissionCarrierConfigGeneral": { - "description": "Configuration for a single transmission carrier.", + "electricity": { + "description": "Configuration for `electricity` settings.", "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", - "type": "boolean" - }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" - }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" + "voltages": { + "description": "Voltage levels to consider.", + "items": { + "type": "number" + }, + "type": "array" }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, - "type": "number" + "base_network": { + "default": "osm", + "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + "enum": [ + "entsoegridkit", + "osm", + "tyndp" + ], + "type": "string" }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, - "type": "number" - } - } - }, - "_TransmissionCarrierConfigHydrogen": { - "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", + "gaslimit_enable": { + "default": false, + "description": "Add an overall absolute gas limit configured in `electricity: gaslimit`.", "type": "boolean" }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" + "gaslimit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "default": false, + "description": "Global gas usage limit." }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" + "co2limit_enable": { + "default": false, + "description": "Add an overall absolute carbon-dioxide emissions limit configured in `electricity: co2limit` in `prepare_network`. **Warning:** This option should currently only be used with electricity-only networks, not for sector-coupled networks.", + "type": "boolean" }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, + "co2limit": { + "default": 77500000.0, + "description": "Cap on total annual system carbon dioxide emissions.", "type": "number" }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, + "co2base": { + "default": 1487000000.0, + "description": "Reference value of total annual system carbon dioxide emissions if relative emission reduction target is specified in `{opts}` wildcard.", "type": "number" }, - "retrofit": { - "description": "Configuration for `transmission.hydrogen.retrofit` settings.", + "operational_reserve": { + "description": "Configuration for `electricity.operational_reserve` settings.", "properties": { - "enable": { + "activate": { "default": false, - "description": "Add option for retrofitting existing pipelines to transport hydrogen.", + "description": "Whether to take operational reserve requirements into account during optimisation.", "type": "boolean" }, - "capacity_per_ch4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "epsilon_load": { + "default": 0.02, + "description": "share of total load.", + "type": "number" + }, + "epsilon_vres": { + "default": 0.02, + "description": "share of total renewable supply.", + "type": "number" + }, + "contingency": { + "default": 4000, + "description": "Fixed reserve capacity (MW).", "type": "number" } } }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", + "max_hours": { + "description": "Configuration for `electricity.max_hours` settings.", "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" + "battery": { + "default": 6, + "description": "Maximum state of charge capacity of the battery in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the battery in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", + "li-ion": { + "default": 6, + "description": "Maximum state of charge capacity of the lithium-ion storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the lithium-ion storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", "type": "number" }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", + "lfp": { + "default": 6, + "description": "Maximum state of charge capacity of the lithium-ion-LFP storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the lithium-ion-LFP storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" + }, + "vanadium": { + "default": 10, + "description": "Maximum state of charge capacity of the vanadium-redox-flow storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the vanadium-redox-flow storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" + }, + "lair": { + "default": 12, + "description": "Maximum state of charge capacity of the liquid-air storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the liquid-air storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" + }, + "pair": { + "default": 24, + "description": "Maximum state of charge capacity of the compressed-air-adiabatic storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the compressed-air-adiabatic storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" + }, + "iron-air": { + "default": 100, + "description": "Maximum state of charge capacity of the iron-air storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the iron-air storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", + "type": "number" + }, + "H2": { + "default": 168, + "description": "Maximum state of charge capacity of the hydrogen storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the hydrogen storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", "type": "number" } } - } - } - }, - "_TransmissionEfficiencyConfig": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } - }, - "_UserAgentConfig": { - "description": "Configuration for `overpass_api.user_agent` settings.", - "properties": { - "project_name": { - "default": "PyPSA-Eur", - "description": "Project name used to identify the user agent of the Overpass API requests.", - "type": "string" - }, - "email": { - "default": "contact@pypsa.org", - "description": "Contact email address for the project using the Overpass API.", - "type": "string" - }, - "website": { - "default": "https://github.com/PyPSA/pypsa-eur", - "description": "Website URL for the project using the Overpass API.", - "type": "string" - } - } - }, - "_WindResourceConfig": { - "description": "Configuration for wind resource settings.", - "properties": { - "method": { - "default": "wind", - "description": "A superordinate technology type.", - "type": "string" }, - "turbine": { - "anyOf": [ - { - "type": "string" - }, - { - "additionalProperties": { + "extendable_carriers": { + "description": "Configuration for `electricity.extendable_carriers` settings.", + "properties": { + "Generator": { + "description": "Defines existing or non-existing conventional and renewable power plants to be extendable during the optimization. Conventional generators can only be built/expanded where already existent today. If a listed conventional carrier is not included in the `conventional_carriers` list, the lower limit of the capacity expansion is set to 0.", + "items": { "type": "string" }, - "type": "object" - } - ], - "description": "Specifies the turbine type and its characteristic power curve. Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available." - }, - "smooth": { - "default": false, - "description": "Switch to apply a gaussian kernel density smoothing to the power curve.", - "type": "boolean" - }, - "add_cutout_windspeed": { - "default": true, - "description": "Whether to add cutout windspeed data.", - "type": "boolean" - } - }, - "required": [ - "turbine" - ] - } - }, - "additionalProperties": true, - "description": "Combined configuration schema for PyPSA-EUR.", - "properties": { - "version": { - "default": "v2026.02.0", - "description": "Version of PyPSA-Eur. Descriptive only.", - "type": "string" - }, - "tutorial": { - "default": false, - "description": "Switch to retrieve the tutorial data set instead of the full data set.", - "type": "boolean" - }, - "logging": { - "description": "Configuration for top level `logging` settings.", - "properties": { - "level": { - "default": "INFO", - "description": "Restrict console outputs to all infos, warning or errors only", - "enum": [ - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL" - ], - "type": "string" - }, - "format": { - "default": "%(levelname)s:%(name)s:%(message)s", - "description": "Custom format for log messages. See `LogRecord `_ attributes.", - "markdownDescription": "Custom format for log messages. See [LogRecord ](https://docs.python.org/3/library/logging.html#logging.LogRecord) attributes.", - "type": "string" - } - } - }, - "remote": { - "description": "Configuration for top level `remote` settings.", - "properties": { - "ssh": { - "default": "", - "description": "Optionally specify the SSH of a remote cluster to be synchronized.", - "type": "string" - }, - "path": { - "default": "", - "description": "Optionally specify the file path within the remote cluster to be synchronized.", - "type": "string" - } - } - }, - "run": { - "description": "Configuration for top level `run` settings.", - "properties": { - "prefix": { - "default": "", - "description": "Prefix for the run name which is used as a top-layer directory name in the results and resources folders.", - "type": "string" - }, - "name": { - "anyOf": [ - { - "type": "string" + "type": "array" }, - { + "StorageUnit": { + "description": "Adds extendable storage units at every node/bus after clustering without capacity limits and with zero initial capacity. Supported technologies include battery, H2, li-ion, vanadium, lfp, lair, pair, and iron-air.", "items": { "type": "string" }, "type": "array" - } - ], - "default": "", - "description": "Specify a name for your run. Results will be stored under this name. If ``scenario: enable:`` is set to ``true``, the name must contain a subset of scenario names defined in ``scenario: file:``. If the name is 'all', all defined scenarios will be run.", - "examples": [ - "my-pypsa-eur-run" - ] - }, - "scenarios": { - "description": "Configuration for `run.scenarios` level.", - "properties": { - "enable": { - "default": false, - "description": "Switch to select whether workflow should generate scenarios based on ``file``.", - "type": "boolean" }, - "file": { - "default": "config/scenarios.yaml", - "description": "Path to the scenario yaml file. The scenario file contains config overrides for each scenario. In order to be taken account, ``run: scenarios`` has to be set to ``true`` and ``run: name`` has to be a subset of top level keys given in the scenario file. In order to automatically create a `scenario.yaml` file based on a combination of settings, alter and use the ``config/create_scenarios.py`` script in the ``config`` directory.", - "examples": [ - "config/scenarios.yaml" - ], - "type": "string" - } - } - }, - "disable_progressbar": { - "default": false, - "description": "Switch to select whether progressbar should be disabled.", - "type": "boolean" - }, - "shared_resources": { - "description": "Configuration for `run.shared_resources` level.", - "properties": { - "policy": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string" - } - ], - "default": false, - "description": "Boolean switch to select whether resources should be shared across runs. If a string is passed, this is used as a subdirectory name for shared resources. If set to 'base', only resources before creating the elec.nc file are shared.", - "examples": [ - false, - "base" - ] + "Store": { + "description": "Adds extendable storage units at every node/bus after clustering without capacity limits and with zero initial capacity. Supported technologies include battery, H2, li-ion, vanadium, lfp, lair, pair, and iron-air.", + "items": { + "type": "string" + }, + "type": "array" }, - "exclude": { - "description": "For the case shared_resources=base, specify additional files that should not be shared across runs.", + "Link": { + "description": "Adds extendable links (H2 pipelines only) at every connection where there are lines or HVDC links without capacity limits and with zero initial capacity. Hydrogen pipelines require hydrogen storage to be modelled as `Store`.", "items": { "type": "string" }, @@ -9127,914 +8673,132 @@ } } }, - "use_shadow_directory": { - "default": false, - "description": "Set to ``true`` (default) if snakemake shadow directories (``shallow``) should be used. Set to ``false`` if problems occur.", - "examples": [ - true + "powerplants_filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } ], - "type": "boolean" - } - } - }, - "foresight": { - "default": "overnight", - "description": "Configuration for `foresight` settings.", - "enum": [ - "overnight", - "myopic", - "perfect" - ], - "type": "string" - }, - "scenario": { - "description": "Configuration for top level `scenario` settings.", - "properties": { - "clusters": { - "description": "List of ``{clusters}`` wildcards to run. Use 'adm' for administrative clustering mode, 'all' for all nodes.", - "items": { - "anyOf": [ - { - "type": "integer" - }, - { - "enum": [ - "adm", - "all" - ], - "type": "string" - } - ] - }, - "type": "array" + "default": "(DateOut > 2025 or DateOut != DateOut) and (DateIn < 2026 or DateIn != DateIn)", + "description": "Filter query for the default powerplant database." }, - "opts": { - "description": "List of ``{opts}`` wildcards to run.", + "custom_powerplants": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ], + "default": false, + "description": "Filter query for the custom powerplant database." + }, + "everywhere_powerplants": { + "description": "List of conventional power plants to add to every node in the model with zero initial capacity. To be used in combination with `extendable_carriers` to allow for building conventional powerplants irrespective of existing locations.", "items": { "type": "string" }, "type": "array" }, - "sector_opts": { - "description": "List of ``{sector_opts}`` wildcards to run.", + "conventional_carriers": { + "description": "List of conventional power plants to include in the model from `resources/powerplants_s_{clusters}.csv`. If an included carrier is also listed in `extendable_carriers`, the capacity is taken as a lower bound.", "items": { "type": "string" }, "type": "array" }, - "planning_horizons": { - "description": "List of ``{planning_horizon}`` wildcards to run.", + "renewable_carriers": { + "description": "List of renewable generators to include in the model.", "items": { - "type": "integer" - }, - "type": "array" - } - } - }, - "countries": { - "default": [ - "AL", - "AT", - "BA", - "BE", - "BG", - "CH", - "CZ", - "DE", - "DK", - "EE", - "ES", - "FI", - "FR", - "GB", - "GR", - "HR", - "HU", - "IE", - "IT", - "LT", - "LU", - "LV", - "ME", - "MK", - "NL", - "NO", - "PL", - "PT", - "RO", - "RS", - "SE", - "SI", - "SK", - "XK" - ], - "description": "Configuration for `countries` settings.", - "items": { - "type": "string" - }, - "type": "array" - }, - "snapshots": { - "description": "Configuration for `snapshots` settings.", - "properties": { - "start": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "2013-01-01", - "description": "Left bound of date range." - }, - "end": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "2014-01-01", - "description": "Right bound of date range." - }, - "inclusive": { - "anyOf": [ - { - "enum": [ - "left", - "right", - "both" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": "left", - "description": "Make the time interval closed to the `left`, `right`, or both sides `both` or neither side `None`." - } - } - }, - "enable": { - "description": "Configuration for `enable` settings.", - "properties": { - "drop_leap_day": { - "default": true, - "description": "Switch to drop February 29 from all time-dependent data in leap years.", - "type": "boolean" - } - } - }, - "co2_budget": { - "anyOf": [ - { - "additionalProperties": { - "type": "number" - }, - "description": "Configuration for `co2_budget` settings.", - "type": "object" - }, - { - "type": "null" - } - ], - "description": "CO2 budget as fraction of 1990 emissions per planning horizon year." - }, - "electricity": { - "description": "Configuration for `electricity` settings.", - "properties": { - "voltages": { - "description": "Voltage levels to consider.", - "items": { - "type": "number" - }, - "type": "array" - }, - "gaslimit_enable": { - "default": false, - "description": "Add an overall absolute gas limit configured in `electricity: gaslimit`.", - "type": "boolean" - }, - "gaslimit": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "default": false, - "description": "Global gas usage limit." - }, - "co2limit_enable": { - "default": false, - "description": "Add an overall absolute carbon-dioxide emissions limit configured in `electricity: co2limit` in `prepare_network`. **Warning:** This option should currently only be used with electricity-only networks, not for sector-coupled networks.", - "type": "boolean" - }, - "co2limit": { - "default": 77500000.0, - "description": "Cap on total annual system carbon dioxide emissions.", - "type": "number" - }, - "co2base": { - "default": 1487000000.0, - "description": "Reference value of total annual system carbon dioxide emissions if relative emission reduction target is specified in `{opts}` wildcard.", - "type": "number" - }, - "operational_reserve": { - "description": "Configuration for `electricity.operational_reserve` settings.", - "properties": { - "activate": { - "default": false, - "description": "Whether to take operational reserve requirements into account during optimisation.", - "type": "boolean" - }, - "epsilon_load": { - "default": 0.02, - "description": "share of total load.", - "type": "number" - }, - "epsilon_vres": { - "default": 0.02, - "description": "share of total renewable supply.", - "type": "number" - }, - "contingency": { - "default": 4000, - "description": "Fixed reserve capacity (MW).", - "type": "number" - } - } - }, - "max_hours": { - "description": "Configuration for `electricity.max_hours` settings.", - "properties": { - "battery": { - "default": 6, - "description": "Maximum state of charge capacity of the battery in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the battery in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "li-ion": { - "default": 6, - "description": "Maximum state of charge capacity of the lithium-ion storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the lithium-ion storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "lfp": { - "default": 6, - "description": "Maximum state of charge capacity of the lithium-ion-LFP storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the lithium-ion-LFP storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "vanadium": { - "default": 10, - "description": "Maximum state of charge capacity of the vanadium-redox-flow storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the vanadium-redox-flow storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "lair": { - "default": 12, - "description": "Maximum state of charge capacity of the liquid-air storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the liquid-air storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "pair": { - "default": 24, - "description": "Maximum state of charge capacity of the compressed-air-adiabatic storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the compressed-air-adiabatic storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "iron-air": { - "default": 100, - "description": "Maximum state of charge capacity of the iron-air storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the iron-air storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - }, - "H2": { - "default": 168, - "description": "Maximum state of charge capacity of the hydrogen storage in terms of hours at full output capacity `p_nom`. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the hydrogen storage in terms of hours at full output capacity `p_nom`. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit).", - "type": "number" - } - } - }, - "extendable_carriers": { - "description": "Configuration for `electricity.extendable_carriers` settings.", - "properties": { - "Generator": { - "description": "Defines existing or non-existing conventional and renewable power plants to be extendable during the optimization. Conventional generators can only be built/expanded where already existent today. If a listed conventional carrier is not included in the `conventional_carriers` list, the lower limit of the capacity expansion is set to 0.", - "items": { - "type": "string" - }, - "type": "array" - }, - "StorageUnit": { - "description": "Adds extendable storage units at every node/bus after clustering without capacity limits and with zero initial capacity. Supported technologies include battery, H2, li-ion, vanadium, lfp, lair, pair, and iron-air.", - "items": { - "type": "string" - }, - "type": "array" - }, - "Store": { - "description": "Adds extendable storage units at every node/bus after clustering without capacity limits and with zero initial capacity. Supported technologies include battery, H2, li-ion, vanadium, lfp, lair, pair, and iron-air.", - "items": { - "type": "string" - }, - "type": "array" - }, - "Link": { - "description": "Adds extendable links (H2 pipelines only) at every connection where there are lines or HVDC links without capacity limits and with zero initial capacity. Hydrogen pipelines require hydrogen storage to be modelled as `Store`.", - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "powerplants_filter": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ], - "default": "(DateOut > 2025 or DateOut != DateOut) and (DateIn < 2026 or DateIn != DateIn)", - "description": "Filter query for the default powerplant database." - }, - "custom_powerplants": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ], - "default": false, - "description": "Filter query for the custom powerplant database." - }, - "everywhere_powerplants": { - "description": "List of conventional power plants to add to every node in the model with zero initial capacity. To be used in combination with `extendable_carriers` to allow for building conventional powerplants irrespective of existing locations.", - "items": { - "type": "string" - }, - "type": "array" - }, - "conventional_carriers": { - "description": "List of conventional power plants to include in the model from `resources/powerplants_s_{clusters}.csv`. If an included carrier is also listed in `extendable_carriers`, the capacity is taken as a lower bound.", - "items": { - "type": "string" - }, - "type": "array" - }, - "renewable_carriers": { - "description": "List of renewable generators to include in the model.", - "items": { - "type": "string" - }, - "type": "array" - }, - "estimate_renewable_capacities": { - "description": "Configuration for `electricity.estimate_renewable_capacities` settings.", - "properties": { - "enable": { - "default": true, - "description": "Activate routine to estimate renewable capacities in rule `add_electricity`. This option should not be used in combination with pathway planning `foresight: myopic` or `foresight: perfect` as renewable capacities are added differently in `add_existing_baseyear`.", - "type": "boolean" - }, - "from_powerplantmatching": { - "default": true, - "description": "Add renewable capacities from powerplantmatching dataset.", - "type": "boolean" - }, - "from_irenastat": { - "default": false, - "description": "Supplement powerplantmatching dataset with heuristics based on country-level renewable capacities from IRENA (IRENASTAT).", - "type": "boolean" - }, - "year": { - "default": 2024, - "description": "Renewable capacities are based on existing capacities reported by IRENA (IRENASTAT) for the specified year.", - "type": "integer" - }, - "expansion_limit": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "default": false, - "description": "Artificially limit maximum IRENA capacities to a factor. For example, an `expansion_limit: 1.1` means 110% of capacities. If false are chosen, the estimated renewable potentials determine by the workflow are used." - }, - "technology_mapping": { - "description": "Configuration for `electricity.estimate_renewable_capacities.technology_mapping` settings.", - "properties": { - "Offshore": { - "default": "offwind-ac", - "description": "PyPSA-Eur carrier that is considered for existing offshore wind technology (IRENA, GEM).", - "type": "string" - }, - "Onshore": { - "default": "onwind", - "description": "PyPSA-Eur carrier that is considered for existing onshore wind capacities (IRENA, GEM).", - "type": "string" - }, - "PV": { - "default": "solar", - "description": "PyPSA-Eur carrier that is considered for existing solar PV capacities (IRENA, GEM).", - "type": "string" - } - } - } - } - }, - "estimate_battery_capacities": { - "default": false, - "description": "Enable estimation of existing battery storage capacities.", - "type": "boolean" - }, - "autarky": { - "description": "Configuration for `electricity.autarky` settings.", - "properties": { - "enable": { - "default": false, - "description": "Require each node to be autarkic by removing all lines and links.", - "type": "boolean" - }, - "by_country": { - "default": false, - "description": "Require each country to be autarkic by removing all cross-border lines and links. `electricity: autarky` must be enabled.", - "type": "boolean" - } - } - } - } - }, - "transmission": { - "description": "Configuration for `transmission` settings.", - "properties": { - "electricity": { - "description": "Configuration for electricity transmission grid.", - "properties": { - "enable": { - "default": true, - "description": "Switch for enabling/disabling the electricity transmission grid.", - "type": "boolean" - }, - "base_network": { - "default": "osm", - "description": "Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - "enum": [ - "entsoegridkit", - "osm", - "tyndp" - ], - "type": "string" - }, - "transmission_limit": { - "default": "vopt", - "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - "type": "string" - }, - "lines": { - "description": "Configuration for `lines` settings.", - "properties": { - "types": { - "additionalProperties": { - "type": "string" - }, - "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", - "type": "object" - }, - "s_max_pu": { - "default": 0.7, - "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - "type": "number" - }, - "s_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable line (MW).", - "type": "number" - }, - "max_extension": { - "default": 20000, - "description": "Upper limit for the extended capacity of each extendable line (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - "type": "number" - }, - "reconnect_crimea": { - "default": true, - "description": "Whether to reconnect Crimea to the Ukrainian grid.", - "type": "boolean" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - }, - "dynamic_line_rating": { - "description": "Configuration for `lines.dynamic_line_rating` settings.", - "properties": { - "activate": { - "default": false, - "description": "Whether to take dynamic line rating into account.", - "type": "boolean" - }, - "cutout": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "default", - "description": "Specifies the weather data cutout file(s) to use." - }, - "correction_factor": { - "default": 0.95, - "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - "type": "number" - }, - "max_voltage_difference": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum voltage angle difference in degrees or 'false' to disable." - }, - "max_line_rating": { - "anyOf": [ - { - "type": "number" - }, - { - "const": false, - "type": "boolean" - } - ], - "default": false, - "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." - } - } - } - } - }, - "links": { - "description": "Configuration for `links` settings.", - "properties": { - "p_max_pu": { - "default": 1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_min_pu": { - "default": -1.0, - "description": "Correction factor for link capacities `p_nom`.", - "type": "number" - }, - "p_nom_max": { - "default": null, - "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", - "type": "number" - }, - "max_extension": { - "default": 30000, - "description": "Upper limit for the extended capacity of each extendable DC link (MW).", - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - "type": "number" - }, - "under_construction": { - "default": "keep", - "description": "Specifies how to handle lines which are currently under construction.", - "enum": [ - "zero", - "remove", - "keep" - ], - "type": "string" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity.links.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for DC links.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.98, - "description": "Static DC transmission efficiency.", - "type": "number" - }, - "efficiency_per_1000km": { - "default": 0.977, - "description": "Distance-dependent DC efficiency factor per 1000 km.", - "type": "number" - } - } - } - } - }, - "transformers": { - "description": "Configuration for `transformers` settings.", - "properties": { - "x": { - "default": 0.1, - "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - "type": "number" - }, - "s_nom": { - "default": 2000.0, - "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - "type": "number" - }, - "type": { - "default": "", - "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", - "type": "string" - } - } - }, - "projects": { - "description": "Configuration for `electricity.projects` settings.", - "properties": { - "enable": { - "default": true, - "description": "Whether to integrate this transmission projects or not.", - "type": "boolean" - }, - "include": { - "description": "Configuration for `electricity.projects.include` settings.", - "properties": { - "tyndp2020": { - "default": true, - "description": "Whether to integrate the TYNDP 2020 dataset.", - "type": "boolean" - }, - "nep": { - "default": true, - "description": "Whether to integrate the German network development plan dataset.", - "type": "boolean" - }, - "manual": { - "default": true, - "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - "type": "boolean" - } - } - }, - "skip": { - "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "status": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - } - ], - "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." - }, - "new_link_capacity": { - "default": "zero", - "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", - "enum": [ - "zero", - "keep" - ], - "type": "string" - } - } - } - } - }, - "electricity_distribution": { - "properties": { - "enable": { - "default": true, - "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - "type": "boolean" - }, - "cost_factor": { - "default": 1.0, - "description": "Multiplies the investment cost of the electricity distribution grid.", - "type": "number" - }, - "efficiency": { - "description": "Configuration for `transmission.electricity_distribution.efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply electricity distribution efficiency.", - "type": "boolean" - }, - "efficiency_static": { - "default": 0.97, - "description": "Static electricity distribution efficiency.", - "type": "number" - } - } - } - }, - "description": "Configuration for simplified electricity distribution grid." - }, - "hydrogen": { - "properties": { - "enable": { - "default": true, - "description": "Enable transmission candidate generation for this carrier.", - "type": "boolean" - }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" - }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" - }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, - "type": "number" - }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, - "type": "number" - }, - "retrofit": { - "description": "Configuration for `transmission.hydrogen.retrofit` settings.", - "properties": { - "enable": { - "default": false, - "description": "Add option for retrofitting existing pipelines to transport hydrogen.", - "type": "boolean" - }, - "capacity_per_ch4": { - "default": 0.6, - "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - "type": "number" - } - } - }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } - } + "type": "string" }, - "description": "Configuration for hydrogen transmission candidates." + "type": "array" }, - "carbon_dioxide": { - "description": "Configuration for a single transmission carrier.", + "estimate_renewable_capacities": { + "description": "Configuration for `electricity.estimate_renewable_capacities` settings.", "properties": { "enable": { "default": true, - "description": "Enable transmission candidate generation for this carrier.", + "description": "Activate routine to estimate renewable capacities in rule `add_electricity`. This option should not be used in combination with pathway planning `foresight: myopic` or `foresight: perfect` as renewable capacities are added differently in `add_existing_baseyear`.", "type": "boolean" }, - "gabriel_filter_min_degree": { - "default": 1, - "description": "Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - "minimum": 0, - "type": "integer" + "from_powerplantmatching": { + "default": true, + "description": "Add renewable capacities from powerplantmatching dataset.", + "type": "boolean" }, - "max_offshore_haversine_distance": { - "default": null, - "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).", - "exclusiveMinimum": 0, - "type": "number" + "from_irenastat": { + "default": false, + "description": "Supplement powerplantmatching dataset with heuristics based on country-level renewable capacities from IRENA (IRENASTAT).", + "type": "boolean" }, - "length_factor": { - "default": 1.25, - "description": "Multiplier applied to geometric corridor lengths.", - "exclusiveMinimum": 0, - "type": "number" + "year": { + "default": 2024, + "description": "Renewable capacities are based on existing capacities reported by IRENA (IRENASTAT) for the specified year.", + "type": "integer" }, - "cost_factor": { - "default": 1, - "description": "Multiplier applied to the capital cost of transmission infrastructure.", - "exclusiveMinimum": 0, - "type": "number" + "expansion_limit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "default": false, + "description": "Artificially limit maximum IRENA capacities to a factor. For example, an `expansion_limit: 1.1` means 110% of capacities. If false are chosen, the estimated renewable potentials determine by the workflow are used." + }, + "technology_mapping": { + "description": "Configuration for `electricity.estimate_renewable_capacities.technology_mapping` settings.", + "properties": { + "Offshore": { + "default": "offwind-ac", + "description": "PyPSA-Eur carrier that is considered for existing offshore wind technology (IRENA, GEM).", + "type": "string" + }, + "Onshore": { + "default": "onwind", + "description": "PyPSA-Eur carrier that is considered for existing onshore wind capacities (IRENA, GEM).", + "type": "string" + }, + "PV": { + "default": "solar", + "description": "PyPSA-Eur carrier that is considered for existing solar PV capacities (IRENA, GEM).", + "type": "string" + } + } } } }, - "gas": { + "estimate_battery_capacities": { + "default": false, + "description": "Enable estimation of existing battery storage capacities.", + "type": "boolean" + }, + "autarky": { + "description": "Configuration for `electricity.autarky` settings.", "properties": { "enable": { - "default": true, - "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "default": false, + "description": "Require each node to be autarkic by removing all lines and links.", "type": "boolean" }, - "connectivity_upgrade": { - "default": 1, - "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", - "type": "number" - }, - "efficiency": { - "description": "Configuration for `transmission..efficiency` settings.", - "properties": { - "enable": { - "default": true, - "description": "Apply transmission efficiency for this carrier.", - "type": "boolean" - }, - "efficiency_per_1000km": { - "default": 1, - "description": "Distance-dependent efficiency factor per 1000 km.", - "type": "number" - }, - "compression_per_1000km": { - "default": 0, - "description": "Additional electricity consumption for compression per 1000 km.", - "type": "number" - } - } + "by_country": { + "default": false, + "description": "Require each country to be autarkic by removing all cross-border lines and links. `electricity: autarky` must be enabled.", + "type": "boolean" } - }, - "description": "Configuration for methane gas transmission candidates." + } + }, + "transmission_limit": { + "default": "vopt", + "description": "Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + "type": "string" } } }, @@ -11237,90 +10001,319 @@ "hydro_max_hours": { "anyOf": [ { - "type": "string" + "type": "string" + }, + { + "type": "number" + } + ], + "default": "energy_capacity_totals_by_country", + "description": "Maximum state of charge capacity of the pumped-hydro storage (PHS) in terms of hours at full output capacity `p_nom` or heuristically determined. Cf. `PyPSA documentation `_.", + "markdownDescription": "Maximum state of charge capacity of the pumped-hydro storage (PHS) in terms of hours at full output capacity `p_nom` or heuristically determined. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit)." + }, + "flatten_dispatch": { + "default": false, + "description": "Consider an upper limit for the hydro dispatch. The limit is given by the average capacity factor plus the buffer given in `flatten_dispatch_buffer`.", + "type": "boolean" + }, + "flatten_dispatch_buffer": { + "default": 0.2, + "description": "If `flatten_dispatch` is true, specify the value added above the average capacity factor.", + "type": "number" + }, + "clip_min_inflow": { + "default": 1.0, + "description": "To avoid too small values in the inflow time series, values below this threshold (MW) are set to zero.", + "type": "number" + }, + "eia_norm_year": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + } + ], + "default": false, + "description": "To specify a specific year by which hydro inflow is normed that deviates from the snapshots' year." + }, + "eia_correct_by_capacity": { + "default": false, + "description": "Correct EIA annual hydro generation data by installed capacity.", + "type": "boolean" + }, + "eia_approximate_missing": { + "default": false, + "description": "Approximate hydro generation data for years not included in EIA dataset through a regression based on annual runoff.", + "type": "boolean" + } + } + } + } + }, + "conventional": { + "additionalProperties": true, + "description": "Configuration for `conventional` settings.", + "properties": { + "unit_commitment": { + "default": false, + "description": "Allow the overwrite of ramp_limit_up, ramp_limit_start_up, ramp_limit_shut_down, p_min_pu, min_up_time, min_down_time, and start_up_cost of conventional generators. Refer to the CSV file 'unit_commitment.csv'.", + "type": "boolean" + }, + "dynamic_fuel_price": { + "default": false, + "description": "Consider the monthly fluctuating fuel prices for each conventional generator. Refer to the CSV file 'data/validation/monthly_fuel_price.csv'.", + "type": "boolean" + }, + "fuel_price_rolling_window": { + "default": 6, + "description": "Monthly rolling mean window for fossil fuel prices smoothing.", + "minimum": 1, + "type": "integer" + }, + "nuclear": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "description": "For any carrier/technology overwrite attributes as listed below.", + "type": "object" + } + } + }, + "lines": { + "description": "Configuration for `lines` settings.", + "properties": { + "types": { + "additionalProperties": { + "type": "string" + }, + "description": "Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + "type": "object" + }, + "s_max_pu": { + "default": 0.7, + "description": "Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + "type": "number" + }, + "s_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable line (MW).", + "type": "number" + }, + "max_extension": { + "default": 20000, + "description": "Upper limit for the extended capacity of each extendable line (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + "type": "number" + }, + "reconnect_crimea": { + "default": true, + "description": "Whether to reconnect Crimea to the Ukrainian grid.", + "type": "boolean" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + }, + "dynamic_line_rating": { + "description": "Configuration for `lines.dynamic_line_rating` settings.", + "properties": { + "activate": { + "default": false, + "description": "Whether to take dynamic line rating into account.", + "type": "boolean" + }, + "cutout": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "default", + "description": "Specifies the weather data cutout file(s) to use." + }, + "correction_factor": { + "default": 0.95, + "description": "Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + "type": "number" + }, + "max_voltage_difference": { + "anyOf": [ + { + "type": "number" }, { - "type": "number" + "const": false, + "type": "boolean" } ], - "default": "energy_capacity_totals_by_country", - "description": "Maximum state of charge capacity of the pumped-hydro storage (PHS) in terms of hours at full output capacity `p_nom` or heuristically determined. Cf. `PyPSA documentation `_.", - "markdownDescription": "Maximum state of charge capacity of the pumped-hydro storage (PHS) in terms of hours at full output capacity `p_nom` or heuristically determined. Cf. [PyPSA documentation ](https://pypsa.readthedocs.io/en/latest/components.html#storage-unit)." - }, - "flatten_dispatch": { "default": false, - "description": "Consider an upper limit for the hydro dispatch. The limit is given by the average capacity factor plus the buffer given in `flatten_dispatch_buffer`.", - "type": "boolean" - }, - "flatten_dispatch_buffer": { - "default": 0.2, - "description": "If `flatten_dispatch` is true, specify the value added above the average capacity factor.", - "type": "number" - }, - "clip_min_inflow": { - "default": 1.0, - "description": "To avoid too small values in the inflow time series, values below this threshold (MW) are set to zero.", - "type": "number" + "description": "Maximum voltage angle difference in degrees or 'false' to disable." }, - "eia_norm_year": { + "max_line_rating": { "anyOf": [ { - "type": "boolean" + "type": "number" }, { - "type": "integer" + "const": false, + "type": "boolean" } ], "default": false, - "description": "To specify a specific year by which hydro inflow is normed that deviates from the snapshots' year." - }, - "eia_correct_by_capacity": { - "default": false, - "description": "Correct EIA annual hydro generation data by installed capacity.", - "type": "boolean" - }, - "eia_approximate_missing": { - "default": false, - "description": "Approximate hydro generation data for years not included in EIA dataset through a regression based on annual runoff.", - "type": "boolean" + "description": "Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable." } } } } }, - "conventional": { - "additionalProperties": true, - "description": "Configuration for `conventional` settings.", + "links": { + "description": "Configuration for `links` settings.", "properties": { - "unit_commitment": { - "default": false, - "description": "Allow the overwrite of ramp_limit_up, ramp_limit_start_up, ramp_limit_shut_down, p_min_pu, min_up_time, min_down_time, and start_up_cost of conventional generators. Refer to the CSV file 'unit_commitment.csv'.", - "type": "boolean" + "p_max_pu": { + "default": 1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" }, - "dynamic_fuel_price": { - "default": false, - "description": "Consider the monthly fluctuating fuel prices for each conventional generator. Refer to the CSV file 'data/validation/monthly_fuel_price.csv'.", + "p_min_pu": { + "default": -1.0, + "description": "Correction factor for link capacities `p_nom`.", + "type": "number" + }, + "p_nom_max": { + "default": null, + "description": "Global upper limit for the maximum capacity of each extendable DC link (MW).", + "type": "number" + }, + "max_extension": { + "default": 30000, + "description": "Upper limit for the extended capacity of each extendable DC link (MW).", + "type": "number" + }, + "length_factor": { + "default": 1.25, + "description": "Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + "type": "number" + }, + "under_construction": { + "default": "keep", + "description": "Specifies how to handle lines which are currently under construction.", + "enum": [ + "zero", + "remove", + "keep" + ], + "type": "string" + } + } + }, + "transmission_projects": { + "description": "Configuration for `transmission_projects` settings.", + "properties": { + "enable": { + "default": true, + "description": "Whether to integrate this transmission projects or not.", "type": "boolean" }, - "fuel_price_rolling_window": { - "default": 6, - "description": "Monthly rolling mean window for fossil fuel prices smoothing.", - "minimum": 1, - "type": "integer" + "include": { + "description": "Configuration for `transmission_projects.include` settings.", + "properties": { + "tyndp2020": { + "default": true, + "description": "Whether to integrate the TYNDP 2020 dataset.", + "type": "boolean" + }, + "nep": { + "default": true, + "description": "Whether to integrate the German network development plan dataset.", + "type": "boolean" + }, + "manual": { + "default": true, + "description": "Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + "type": "boolean" + } + } }, - "nuclear": { - "additionalProperties": { - "anyOf": [ - { + "skip": { + "description": "Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "number" - } - ] - }, - "description": "For any carrier/technology overwrite attributes as listed below.", - "type": "object" + "type": "array" + }, + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + ], + "description": "Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`." + }, + "new_link_capacity": { + "default": "zero", + "description": "Whether to set the new link capacity to the provided capacity or set it to zero.", + "enum": [ + "zero", + "keep" + ], + "type": "string" + } + } + }, + "transformers": { + "description": "Configuration for `transformers` settings.", + "properties": { + "x": { + "default": 0.1, + "description": "Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + "type": "number" + }, + "s_nom": { + "default": 2000.0, + "description": "Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + "type": "number" + }, + "type": { + "default": "", + "description": "Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + "type": "string" } } }, @@ -12183,6 +11176,16 @@ "description": "Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", "type": "boolean" }, + "co2_network": { + "default": true, + "description": "Add option for planning a new carbon dioxide transmission network.", + "type": "boolean" + }, + "co2_network_cost_factor": { + "default": 1, + "description": "The cost factor for the capital cost of the carbon dioxide transmission network.", + "type": "number" + }, "cc_fraction": { "default": 0.9, "description": "The default fraction of CO2 captured with post-combustion capture.", @@ -12304,14 +11307,114 @@ "description": "Add option for using waste heat of electrolysis in district heating networks.", "type": "number" }, - "electricity_grid_connection_cost": { + "electricity_transmission_grid": { + "default": true, + "description": "Switch for enabling/disabling the electricity transmission grid.", + "type": "boolean" + }, + "electricity_distribution_grid": { + "default": true, + "description": "Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + "type": "boolean" + }, + "electricity_distribution_grid_cost_factor": { + "default": 1.0, + "description": "Multiplies the investment cost of the electricity distribution grid.", + "type": "number" + }, + "electricity_grid_connection": { "default": true, "description": "Add the cost of electricity grid connection for onshore wind and solar.", "type": "boolean" }, - "gas_distribution_grid_cost": { + "transmission_efficiency": { + "description": "Configuration for `sector.transmission_efficiency` settings.", + "properties": { + "enable": { + "description": "Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", + "items": { + "type": "string" + }, + "type": "array" + }, + "DC": { + "additionalProperties": { + "type": "number" + }, + "description": "DC transmission efficiency.", + "type": "object" + }, + "H2 pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "H2 pipeline transmission efficiency.", + "type": "object" + }, + "gas pipeline": { + "additionalProperties": { + "type": "number" + }, + "description": "Gas pipeline transmission efficiency.", + "type": "object" + }, + "electricity distribution grid": { + "additionalProperties": { + "type": "number" + }, + "description": "Electricity distribution grid efficiency.", + "type": "object" + } + } + }, + "H2_network": { + "default": true, + "description": "Add option for new hydrogen pipelines.", + "type": "boolean" + }, + "pipeline_topology": { + "description": "Configuration for `sector.pipeline_topology` settings.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate pipeline (km).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "gas_network": { + "default": true, + "description": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "markdownDescription": "Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + "type": "boolean" + }, + "H2_retrofit": { + "default": false, + "description": "Add option for retrofiting existing pipelines to transport hydrogen.", + "type": "boolean" + }, + "H2_retrofit_capacity_per_CH4": { + "default": 0.6, + "description": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "markdownDescription": "The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The [European Hydrogen Backbone (April, 2020) p.15 ](https://gasforclimate2050.eu/wp-content/uploads/2020/07/2020_European-Hydrogen-Backbone_Report.pdf) 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + "type": "number" + }, + "gas_network_connectivity_upgrade": { + "default": 1, + "description": "The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + "markdownDescription": "The number of desired edge connectivity (k) in the length-weighted [k-edge augmentation algorithm ](https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation.html#networkx.algorithms.connectivity.edge_augmentation.k_edge_augmentation) used for the gas network.", + "type": "number" + }, + "gas_distribution_grid": { "default": true, - "description": "Add gas distribution grid costs to gas-consuming components.", + "description": "Add a gas distribution grid.", "type": "boolean" }, "gas_distribution_grid_cost_factor": { diff --git a/config/test/config.electricity.yaml b/config/test/config.electricity.yaml index ba4e93bf2d..7e13435c5f 100644 --- a/config/test/config.electricity.yaml +++ b/config/test/config.electricity.yaml @@ -68,12 +68,10 @@ clustering: temporal: resolution_elec: 24h -transmission: - electricity: - lines: - dynamic_line_rating: - activate: true - max_line_rating: 1.3 +lines: + dynamic_line_rating: + activate: true + max_line_rating: 1.3 solving: diff --git a/config/test/config.myopic.yaml b/config/test/config.myopic.yaml index 0dc89b2718..e76df5a395 100644 --- a/config/test/config.myopic.yaml +++ b/config/test/config.myopic.yaml @@ -56,6 +56,7 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false + co2_network: false methanol: methanol_to_power: ocgt: false @@ -77,10 +78,6 @@ electricity: estimate_renewable_capacities: enable: false -transmission: - carbon_dioxide: - enable: false - atlite: default_cutout: be-03-2013-era5 cutouts: diff --git a/config/test/config.overnight.yaml b/config/test/config.overnight.yaml index b83fbb8a01..f2ba5209e5 100644 --- a/config/test/config.overnight.yaml +++ b/config/test/config.overnight.yaml @@ -36,15 +36,6 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] -transmission: - hydrogen: - retrofit: - enable: true - carbon_dioxide: - enable: false - gas: - enable: true - atlite: default_cutout: be-03-2013-era5 cutouts: @@ -68,6 +59,8 @@ clustering: resolution_sector: 24h sector: + gas_network: true + H2_retrofit: true residential_heat: dsm: enable: true @@ -103,6 +96,7 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false + co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/config/test/config.perfect.yaml b/config/test/config.perfect.yaml index 1659766f30..16a5199ad3 100644 --- a/config/test/config.perfect.yaml +++ b/config/test/config.perfect.yaml @@ -39,10 +39,6 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] -transmission: - carbon_dioxide: - enable: false - sector: min_part_load_fischer_tropsch: 0 min_part_load_methanolisation: 0 @@ -72,6 +68,7 @@ sector: regional_co2_sequestration_potential: enable: false co2_spatial: false + co2_network: false methanol: methanol_to_power: ocgt: false diff --git a/config/test/config.tyndp.yaml b/config/test/config.tyndp.yaml index 4fffe83c79..7d3cc73073 100644 --- a/config/test/config.tyndp.yaml +++ b/config/test/config.tyndp.yaml @@ -22,6 +22,9 @@ snapshots: end: "2013-03-08" electricity: + base_network: tyndp + transmission_limit: v1.0 + extendable_carriers: Generator: [OCGT] StorageUnit: [battery] @@ -30,19 +33,6 @@ electricity: renewable_carriers: [solar, solar-hsat, onwind, offwind-ac, offwind-dc, offwind-float] -transmission: - electricity: - base_network: tyndp - transmission_limit: v1.0 - links: - p_min_pu: 0 - projects: - enable: true - include: - tyndp2020: false - nep: false - manual: false - atlite: default_cutout: europe-2013-03-sarah3-era5 cutouts: @@ -58,6 +48,22 @@ renewable: max_depth: false min_depth: false +links: + p_min_pu: 0 + +transmission_projects: + include: + tyndp2020: false + nep: false + manual: false + +sector: + transmission_efficiency: + enable: + - H2 pipeline + - gas pipeline + - electricity distribution grid + clustering: mode: administrative administrative: diff --git a/doc/configuration.rst b/doc/configuration.rst index e5852c045f..ae4bfb2cdc 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -288,54 +288,6 @@ Switches for some rules and optional features. :start-at: electricity: :end-before: # docs -.. _transmission_cf: - -``transmission`` -================ - -Configure transmission related settings for all carriers. - -For non electric carriers, the transmission candidates are built based on -Delaunay triangulation and optional Gabriel filtering. The minimum degree -specifies the minimum connectedness of the candidate graph. - -.. _transmission_projects_cf: - -``electricity.projects`` ------------------------- - -Allows to define additional transmission projects that will be added to the -base network, e.g., from the TYNDP 2020 dataset. The projects are read in -from the CSV files in the subfolder of ``data/transmission_projects/``. New -transmission projects, e.g. from TYNDP 2024, can be added in a new subfolder -of transmission projects, e.g. ``data/transmission_projects/tyndp2024`` while -extending the list of projects in ``transmission.electricity.projects.include`` -in the configuration. The CSV files in the project folder should have the same -columns as the CSV files in the template folder -``data/transmission_projects/template``. - -.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionProjectsConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: projects: - :end-before: hydrogen: - -.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionConfig - :lift_description: - :hide_key: /**/additionalProperties - -**YAML Syntax** - -.. literalinclude:: ../config/config.default.yaml - :language: yaml - :start-at: transmission: - :end-before: # docs - .. _atlite_cf: ``atlite`` @@ -474,6 +426,72 @@ overwrite the existing values. :start-at: conventional: :end-before: # docs +.. _lines_cf: + +``lines`` +========= + +.. jsonschema:: ../config/schema.default.json#/$defs/LinesConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: lines: + :end-before: # docs + +.. _links_cf: + +``links`` +============= + +.. jsonschema:: ../config/schema.default.json#/$defs/LinksConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: links: + :end-before: # docs + +.. _transmission_projects_cf: + +``transmission_projects`` +========================= + +Allows to define additional transmission projects that will be added to the base network, e.g., from the TYNDP 2020 dataset. The projects are read in from the CSV files in the subfolder of ``data/transmission_projects/``. New transmission projects, e.g. from TYNDP 2024, can be added in a new subfolder of transmission projects, e.g. ``data/transmission_projects/tyndp2024`` while extending the list of ``transmission_projects`` in the ``config.yaml`` by ``tyndp2024``. The CSV files in the project folder should have the same columns as the CSV files in the template folder ``data/transmission_projects/template``. + +.. jsonschema:: ../config/schema.default.json#/$defs/TransmissionProjectsConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: transmission_projects: + :end-before: # docs + +.. _transformers_cf: + +``transformers`` +================ + +.. jsonschema:: ../config/schema.default.json#/$defs/TransformersConfig + :lift_description: + :hide_key: /**/additionalProperties + +**YAML Syntax** + +.. literalinclude:: ../config/config.default.yaml + :language: yaml + :start-at: transformers: + :end-before: # docs + .. _load_cf: ``load`` diff --git a/doc/preparation.rst b/doc/preparation.rst index 7a29fab66b..a6cce4d5bb 100644 --- a/doc/preparation.rst +++ b/doc/preparation.rst @@ -79,20 +79,20 @@ Rule ``build_natura`` .. automodule:: build_natura -Rule ``build_electricity_transmission_projects`` +Rule ``build_transmission_projects`` ==================================== -.. automodule:: build_electricity_transmission_projects +.. automodule:: build_transmission_projects Rule ``build_line_rating`` ================================= .. automodule:: build_line_rating -Rule ``add_electricity_transmission_projects_and_dlr`` +Rule ``add_transmission_projects_and_dlr`` =========================================== -.. automodule:: add_electricity_transmission_projects_and_dlr +.. automodule:: add_transmission_projects_and_dlr Rule ``build_bidding_zones`` ============================= diff --git a/doc/release_notes.rst b/doc/release_notes.rst index f912d677d5..c707e83566 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -22,26 +22,6 @@ Release Notes * feat: Improve the config validation to cover scenario management (https://github.com/PyPSA/pypsa-eur/pull/2155). -* feat: Add new rule :mod:`build_transmission_topology` to generate greenfield pipeline investment candidates - (H2, CO2) using Delaunay triangulation with optional Gabriel graph filtering and minimum-degree backfilling, - replacing the former AC-topology-based approach. A ``max_offshore_haversine_distance`` setting per carrier - limits offshore corridor lengths (https://github.com/PyPSA/pypsa-eur/pull/2153). - -* refactor: Rename ``transmission_projects`` to ``transmission.electricity.projects`` and - ``gas_distribution_grid`` to ``gas_distribution_grid_cost`` to better reflect their semantics - (https://github.com/PyPSA/pypsa-eur/pull/2153). Rename `sector.electricity_grid_connection` to `sector.electricity_grid_connection_cost` - and `gas_distribution_grid` to `gas_distribution_grid_cost`. - -**Breaking changes** - -* Consolidate all carrier-specific transmission settings under a new top-level ``transmission`` config - key with sub-keys per carrier (``electricity``, ``hydrogen``, ``carbon_dioxide``, ``gas``, - ``electricity_distribution``), replacing the previously scattered ``lines``, ``links``, ``transformers``, - ``transmission_projects``, ``base_network``, ``transmission_limit``, and sector-level keys such as - ``H2_network``, ``gas_network``, ``co2_network``, ``transmission_efficiency``, ``H2_retrofit``, and - ``gas_distribution_grid``. Carrier efficiencies, H2 retrofit settings, and distribution grid costs are - now nested under the respective carrier sub-key (https://github.com/PyPSA/pypsa-eur/pull/2153). - PyPSA-Eur v2026.02.0 (18th February 2026) ========================================= diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 2a6f18c271..a80a63f202 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -172,12 +172,12 @@ This triggers a workflow of multiple preceding jobs that depend on each rule's i 17[label = "retrieve_jrc_ardeco", color = "0.06 0.6 0.85", style="rounded"]; 18[label = "cluster_network\nclusters: 6", color = "0.12 0.6 0.85", style="rounded"]; 19[label = "simplify_network", color = "0.33 0.6 0.85", style="rounded"]; - 20[label = "add_electricity_transmission_projects_and_dlr", color = "0.46 0.6 0.85", style="rounded"]; + 20[label = "add_transmission_projects_and_dlr", color = "0.46 0.6 0.85", style="rounded"]; 21[label = "base_network", color = "0.00 0.6 0.85", style="rounded"]; 22[label = "retrieve_osm_prebuilt", color = "0.41 0.6 0.85", style="rounded"]; 23[label = "build_line_rating", color = "0.57 0.6 0.85", style="rounded"]; 24[label = "retrieve_cutout\ncutout: be-03-2013-era5", color = "0.18 0.6 0.85", style="rounded"]; - 25[label = "build_electricity_transmission_projects", color = "0.04 0.6 0.85", style="rounded"]; + 25[label = "build_transmission_projects", color = "0.04 0.6 0.85", style="rounded"]; 26[label = "build_electricity_demand_base", color = "0.61 0.6 0.85", style="rounded"]; 27[label = "build_electricity_demand", color = "0.16 0.6 0.85", style="rounded"]; 28[label = "retrieve_electricity_demand", color = "0.21 0.6 0.85", style="rounded"]; @@ -307,36 +307,36 @@ In the terminal, this will show up as a list of jobs to be run: Building DAG of jobs... Job stats: - job count - ------------------------------------- ------- - add_electricity 1 - add_electricity_transmission_projects_and_dlr 1 - base_network 1 - build_electricity_demand 1 - build_electricity_demand_base 1 - build_line_rating 1 - build_osm_boundaries 4 - build_powerplants 1 - build_renewable_profiles 6 - build_shapes 1 - build_ship_raster 1 - build_electricity_transmission_projects 1 - cluster_network 1 - determine_availability_matrix 6 - prepare_network 1 - retrieve_cost_data 1 - retrieve_databundle 1 - retrieve_eez 1 - retrieve_electricity_demand 1 - retrieve_jrc_ardeco 1 - retrieve_nuts_2021_shapes 1 - retrieve_osm_boundaries 4 - retrieve_osm_prebuilt 1 - retrieve_ship_raster 1 - retrieve_synthetic_electricity_demand 1 - simplify_network 1 - solve_network 1 - total 43 + job count + ------------------------------------- ------- + add_electricity 1 + add_transmission_projects_and_dlr 1 + base_network 1 + build_electricity_demand 1 + build_electricity_demand_base 1 + build_line_rating 1 + build_osm_boundaries 4 + build_powerplants 1 + build_renewable_profiles 6 + build_shapes 1 + build_ship_raster 1 + build_transmission_projects 1 + cluster_network 1 + determine_availability_matrix 6 + prepare_network 1 + retrieve_cost_data 1 + retrieve_databundle 1 + retrieve_eez 1 + retrieve_electricity_demand 1 + retrieve_jrc_ardeco 1 + retrieve_nuts_2021_shapes 1 + retrieve_osm_boundaries 4 + retrieve_osm_prebuilt 1 + retrieve_ship_raster 1 + retrieve_synthetic_electricity_demand 1 + simplify_network 1 + solve_network 1 + total 43 ``snakemake`` then runs these jobs in the correct order. diff --git a/doc/tutorial_sector.rst b/doc/tutorial_sector.rst index a1b923d750..7044198716 100644 --- a/doc/tutorial_sector.rst +++ b/doc/tutorial_sector.rst @@ -68,7 +68,7 @@ which were already included in the electricity-only tutorial: job count ------------------------------------------------ ------- add_electricity 1 - add_electricity_transmission_projects_and_dlr 1 + add_transmission_projects_and_dlr 1 all 1 base_network 1 build_ammonia_production 1 @@ -105,7 +105,7 @@ which were already included in the electricity-only tutorial: build_ship_raster 1 build_shipping_demand 1 build_temperature_profiles 1 - build_electricity_transmission_projects 1 + build_transmission_projects 1 build_transport_demand 1 cluster_gas_network 1 cluster_network 1 @@ -186,10 +186,10 @@ successfully. 22[label = "retrieve_jrc_ardeco", color = "0.22 0.6 0.85", style="rounded"]; 23[label = "cluster_network\nclusters: 5", color = "0.39 0.6 0.85", style="rounded"]; 24[label = "simplify_network", color = "0.13 0.6 0.85", style="rounded"]; - 25[label = "add_electricity_transmission_projects_and_dlr", color = "0.44 0.6 0.85", style="rounded"]; + 25[label = "add_transmission_projects_and_dlr", color = "0.44 0.6 0.85", style="rounded"]; 26[label = "base_network", color = "0.62 0.6 0.85", style="rounded"]; 27[label = "retrieve_osm_prebuilt", color = "0.52 0.6 0.85", style="rounded"]; - 28[label = "build_electricity_transmission_projects", color = "0.32 0.6 0.85", style="rounded"]; + 28[label = "build_transmission_projects", color = "0.32 0.6 0.85", style="rounded"]; 29[label = "build_electricity_demand_base", color = "0.47 0.6 0.85", style="rounded"]; 30[label = "build_electricity_demand", color = "0.34 0.6 0.85", style="rounded"]; 31[label = "retrieve_electricity_demand", color = "0.61 0.6 0.85", style="rounded"]; @@ -560,10 +560,10 @@ workflow: 23[label = "retrieve_jrc_ardeco", color = "0.29 0.6 0.85", style="rounded"]; 24[label = "cluster_network\nclusters: 5", color = "0.30 0.6 0.85", style="rounded"]; 25[label = "simplify_network", color = "0.02 0.6 0.85", style="rounded"]; - 26[label = "add_electricity_transmission_projects_and_dlr", color = "0.60 0.6 0.85", style="rounded"]; + 26[label = "add_transmission_projects_and_dlr", color = "0.60 0.6 0.85", style="rounded"]; 27[label = "base_network", color = "0.30 0.6 0.85", style="rounded"]; 28[label = "retrieve_osm_prebuilt", color = "0.43 0.6 0.85", style="rounded"]; - 29[label = "build_electricity_transmission_projects", color = "0.51 0.6 0.85", style="rounded"]; + 29[label = "build_transmission_projects", color = "0.51 0.6 0.85", style="rounded"]; 30[label = "build_electricity_demand_base", color = "0.64 0.6 0.85", style="rounded"]; 31[label = "build_electricity_demand", color = "0.16 0.6 0.85", style="rounded"]; 32[label = "retrieve_electricity_demand", color = "0.33 0.6 0.85", style="rounded"]; diff --git a/rules/build_electricity.smk b/rules/build_electricity.smk index b207740514..bb6c4d270b 100755 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -60,7 +60,7 @@ rule build_powerplants: def input_base_network(w): - base_network = config_provider("transmission", "electricity", "base_network")(w) + base_network = config_provider("electricity", "base_network")(w) source = config_provider("data", "osm", "source")(w) components = {"buses", "lines", "links", "converters", "transformers"} if (base_network == "osm") and (source == "archive"): @@ -100,9 +100,9 @@ rule base_network: countries=config_provider("countries"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), - lines=config_provider("transmission", "electricity", "lines"), - links=config_provider("transmission", "electricity", "links"), - transformers=config_provider("transmission", "electricity", "transformers"), + lines=config_provider("lines"), + links=config_provider("links"), + transformers=config_provider("transformers"), clustering=config_provider("clustering", "mode"), admin_levels=config_provider("clustering", "administrative"), message: @@ -477,10 +477,7 @@ rule build_line_rating: input: base_network=resources("networks/base.nc"), cutout=lambda w: input_cutout( - w, - config_provider( - "transmission", "electricity", "lines", "dynamic_line_rating", "cutout" - )(w), + w, config_provider("lines", "dynamic_line_rating", "cutout")(w) ), output: output=resources("dlr.nc"), @@ -500,87 +497,77 @@ rule build_line_rating: scripts("build_line_rating.py") -rule build_electricity_transmission_projects: +rule build_transmission_projects: input: base_network=resources("networks/base.nc"), offshore_shapes=resources("offshore_shapes.geojson"), europe_shape=resources("europe_shape.geojson"), transmission_projects=lambda w: [ "data/transmission_projects/" + name - for name, include in config_provider( - "transmission", "electricity", "projects", "include" - )(w).items() + for name, include in config_provider("transmission_projects", "include")( + w + ).items() if include ], output: - new_lines=resources("transmission/electricity_projects/new_lines.csv"), - new_links=resources("transmission/electricity_projects/new_links.csv"), - adjust_lines=resources("transmission/electricity_projects/adjust_lines.csv"), - adjust_links=resources("transmission/electricity_projects/adjust_links.csv"), - new_buses=resources("transmission/electricity_projects/new_buses.csv"), + new_lines=resources("transmission_projects/new_lines.csv"), + new_links=resources("transmission_projects/new_links.csv"), + adjust_lines=resources("transmission_projects/adjust_lines.csv"), + adjust_links=resources("transmission_projects/adjust_links.csv"), + new_buses=resources("transmission_projects/new_buses.csv"), log: - logs("build_electricity_transmission_projects.log"), + logs("build_transmission_projects.log"), benchmark: - benchmarks("build_electricity_transmission_projects") + benchmarks("build_transmission_projects") threads: 1 resources: mem_mb=4000, params: - transmission_projects=config_provider("transmission", "electricity", "projects"), - line_factor=config_provider( - "transmission", "electricity", "lines", "length_factor" - ), - s_max_pu=config_provider("transmission", "electricity", "lines", "s_max_pu"), + transmission_projects=config_provider("transmission_projects"), + line_factor=config_provider("lines", "length_factor"), + s_max_pu=config_provider("lines", "s_max_pu"), message: - "Building electricity transmission projects" + "Building transmission projects" script: - scripts("build_electricity_transmission_projects.py") + scripts("build_transmission_projects.py") -rule add_electricity_transmission_projects_and_dlr: +rule add_transmission_projects_and_dlr: input: network=resources("networks/base.nc"), dlr=lambda w: ( resources("dlr.nc") - if config_provider( - "transmission", - "electricity", - "lines", - "dynamic_line_rating", - "activate", - )(w) + if config_provider("lines", "dynamic_line_rating", "activate")(w) else [] ), transmission_projects=lambda w: ( [ - resources("transmission/electricity_projects/new_buses.csv"), - resources("transmission/electricity_projects/new_lines.csv"), - resources("transmission/electricity_projects/new_links.csv"), - resources("transmission/electricity_projects/adjust_lines.csv"), - resources("transmission/electricity_projects/adjust_links.csv"), + resources("transmission_projects/new_buses.csv"), + resources("transmission_projects/new_lines.csv"), + resources("transmission_projects/new_links.csv"), + resources("transmission_projects/adjust_lines.csv"), + resources("transmission_projects/adjust_links.csv"), ] - if config_provider("transmission", "electricity", "projects", "enable")(w) + if config_provider("transmission_projects", "enable")(w) else [] ), output: network=resources("networks/base_extended.nc"), log: - logs("add_electricity_transmission_projects_and_dlr.log"), + logs("add_transmission_projects_and_dlr.log"), benchmark: - benchmarks("add_electricity_transmission_projects_and_dlr") + benchmarks("add_transmission_projects_and_dlr") threads: 1 resources: mem_mb=4000, params: - transmission_projects=config_provider("transmission", "electricity", "projects"), - dlr=config_provider( - "transmission", "electricity", "lines", "dynamic_line_rating" - ), - s_max_pu=config_provider("transmission", "electricity", "lines", "s_max_pu"), + transmission_projects=config_provider("transmission_projects"), + dlr=config_provider("lines", "dynamic_line_rating"), + s_max_pu=config_provider("lines", "s_max_pu"), message: "Adding transmission projects and dynamic line ratings" script: - scripts("add_electricity_transmission_projects_and_dlr.py") + scripts("add_transmission_projects_and_dlr.py") def input_class_regions(w): @@ -690,12 +677,8 @@ rule simplify_network: aggregation_strategies=config_provider( "clustering", "aggregation_strategies", default={} ), - p_max_pu=config_provider( - "transmission", "electricity", "links", "p_max_pu", default=1.0 - ), - p_min_pu=config_provider( - "transmission", "electricity", "links", "p_min_pu", default=-1.0 - ), + p_max_pu=config_provider("links", "p_max_pu", default=1.0), + p_min_pu=config_provider("links", "p_min_pu", default=-1.0), message: "Simplifying network" script: @@ -711,11 +694,11 @@ def input_custom_busmap(w): mode = config_provider("clustering", "mode", default="busmap")(w) if mode == "custom_busmap": - base_network = config_provider("transmission", "electricity", "base_network")(w) + base_network = config_provider("electricity", "base_network")(w) custom_busmap = f"data/busmaps/base_s_{w.clusters}_{base_network}.csv" if mode == "custom_busshapes": - base_network = config_provider("transmission", "electricity", "base_network")(w) + base_network = config_provider("electricity", "base_network")(w) custom_busshapes = f"data/busshapes/base_s_{w.clusters}_{base_network}.geojson" return { @@ -770,9 +753,7 @@ rule cluster_network: "electricity", "conventional_carriers", default=[] ), max_hours=config_provider("electricity", "max_hours"), - length_factor=config_provider( - "transmission", "electricity", "lines", "length_factor" - ), + length_factor=config_provider("lines", "length_factor"), cluster_mode=config_provider("clustering", "mode"), copperplate_regions=config_provider("clustering", "copperplate_regions"), message: @@ -836,12 +817,8 @@ rule add_electricity: resources: mem_mb=10000, params: - line_length_factor=config_provider( - "transmission", "electricity", "lines", "length_factor" - ), - link_length_factor=config_provider( - "transmission", "electricity", "links", "length_factor" - ), + line_length_factor=config_provider("lines", "length_factor"), + link_length_factor=config_provider("links", "length_factor"), scaling_factor=config_provider("load", "scaling_factor"), countries=config_provider("countries"), snapshots=config_provider("snapshots"), @@ -883,8 +860,8 @@ rule prepare_network: mem_mb=4000, params: time_resolution=config_provider("clustering", "temporal", "resolution_elec"), - links=config_provider("transmission", "electricity", "links"), - lines=config_provider("transmission", "electricity", "lines"), + links=config_provider("links"), + lines=config_provider("lines"), co2base=config_provider("electricity", "co2base"), co2limit_enable=config_provider("electricity", "co2limit_enable", default=False), co2limit=config_provider("electricity", "co2limit"), @@ -894,9 +871,7 @@ rule prepare_network: adjustments=config_provider("adjustments", "electricity"), autarky=config_provider("electricity", "autarky", default={}), drop_leap_day=config_provider("enable", "drop_leap_day"), - transmission_limit=config_provider( - "transmission", "electricity", "transmission_limit" - ), + transmission_limit=config_provider("electricity", "transmission_limit"), message: "Preparing network for model with {wildcards.clusters} clusters and options {wildcards.opts}" script: @@ -983,7 +958,7 @@ rule build_osm_network: params: countries=config_provider("countries"), voltages=config_provider("electricity", "voltages"), - line_types=config_provider("transmission", "electricity", "lines", "types"), + line_types=config_provider("lines", "types"), under_construction=config_provider("osm_network_release", "under_construction"), remove_after=config_provider("osm_network_release", "remove_after"), message: diff --git a/rules/build_sector.smk b/rules/build_sector.smk index d9b736b69d..e9e189a1ba 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -2,8 +2,6 @@ # # SPDX-License-Identifier: MIT -import math - rule build_population_layouts: input: @@ -149,45 +147,36 @@ rule cluster_gas_network: rule build_transmission_delaunay_graph: + """Builds the Delaunay graph between clustered buses as candidate pipeline corridors.""" input: network=resources("networks/base_s_{clusters}.nc"), offshore_shapes=resources("offshore_shapes.geojson"), output: - delaunay_graph=resources("transmission/delaunay_graph_{clusters}.geojson"), + delaunay_graph=resources("delaunay_graph_base_s_{clusters}.geojson"), log: logs("build_transmission_delaunay_graph_{clusters}.log"), benchmark: - benchmarks("build_transmission_delaunay_graph/{clusters}") + benchmarks("build_transmission_delaunay_graph/s_{clusters}") resources: mem_mb=4000, - message: - "Building Delaunay triangulation graph for {wildcards.clusters} clusters" script: scripts("build_transmission_delaunay_graph.py") rule build_transmission_topology: + """Filters the Delaunay graph to the candidate corridors for new H2 and CO2 pipelines.""" input: - delaunay_graph=resources("transmission/delaunay_graph_{clusters}.geojson"), + delaunay_graph=resources("delaunay_graph_base_s_{clusters}.geojson"), output: - candidates=resources( - "transmission/candidates_{clusters}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson" - ), + candidates=resources("transmission_candidates_base_s_{clusters}.geojson"), log: - logs( - "build_transmission_topology_{clusters}_min_{min_degree}_maxoffdist_{max_offdist}.log" - ), + logs("build_transmission_topology_{clusters}.log"), benchmark: - benchmarks( - "build_transmission_topology/{clusters}_min_{min_degree}_maxoffdist_{max_offdist}" - ) + benchmarks("build_transmission_topology/s_{clusters}") resources: mem_mb=2000, params: - min_degree=lambda w: int(w.min_degree), - max_offdist=lambda w: float(w.max_offdist), - message: - "Building transmission topology for {wildcards.clusters} clusters (min_degree={wildcards.min_degree}, max_offdist={wildcards.max_offdist} km)" + pipeline_topology=config_provider("sector", "pipeline_topology"), script: scripts("build_transmission_topology.py") @@ -1608,34 +1597,13 @@ def input_heat_source_power(w): } -def input_transmission_candidates(w): - """Generate transmission candidate file inputs for enabled transmission carriers.""" - candidates = {} - for carrier in ("hydrogen", "carbon_dioxide"): - carrier_cfg = config["transmission"][carrier] - if not carrier_cfg["enable"]: - continue - key = f"{carrier}_transmission_candidates" - min_degree = carrier_cfg["gabriel_filter_min_degree"] - max_offdist = carrier_cfg["max_offshore_haversine_distance"] - if min_degree > 0 or max_offdist < float("inf"): - candidates[key] = resources( - f"transmission/candidates_{{clusters}}_min_{min_degree}_maxoffdist_{max_offdist}_km.geojson" - ) - else: - candidates[key] = resources( - "transmission/delaunay_graph_{clusters}.geojson" - ) - return candidates - - rule prepare_sector_network: input: unpack(input_profile_offwind), unpack(input_heat_source_power), - unpack(input_transmission_candidates), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, + **rules.build_transmission_topology.output, snapshot_weightings=resources( "snapshot_weightings_base_s_{clusters}_elec_{opts}_{sector_opts}.csv" ), @@ -1782,7 +1750,9 @@ rule prepare_sector_network: sector=config_provider("sector"), industry=config_provider("industry"), renewable=config_provider("renewable"), + lines=config_provider("lines"), pypsa_eur=config_provider("pypsa_eur"), + length_factor=config_provider("lines", "length_factor"), planning_horizons=config_provider("scenario", "planning_horizons"), countries=config_provider("countries"), adjustments=config_provider("adjustments", "sector"), @@ -1803,7 +1773,6 @@ rule prepare_sector_network: temperature_limited_stores=config_provider( "sector", "district_heating", "temperature_limited_stores" ), - transmission=config_provider("transmission"), message: "Preparing integrated sector-coupled energy network for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizon, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: diff --git a/rules/development.smk b/rules/development.smk index 809270a0f1..88bad702a1 100644 --- a/rules/development.smk +++ b/rules/development.smk @@ -26,9 +26,9 @@ rule base_network_incumbent: countries=config_provider("countries"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), - lines=config_provider("transmission", "electricity", "lines"), - links=config_provider("transmission", "electricity", "links"), - transformers=config_provider("transmission", "electricity", "transformers"), + lines=config_provider("lines"), + links=config_provider("links"), + transformers=config_provider("transformers"), clustering=config_provider("clustering", "mode"), admin_levels=config_provider("clustering", "administrative"), message: @@ -54,7 +54,7 @@ rule make_network_comparison: mem_mb=2000, params: countries=config_provider("countries"), - base_network=config_provider("transmission", "electricity", "base_network"), + base_network=config_provider("electricity", "base_network"), compare_to_version=config_provider( "osm_network_release", "compare_to", "version" ), @@ -85,7 +85,7 @@ rule prepare_osm_network_release: resources: mem_mb=1000, params: - line_types=config["transmission"]["electricity"]["lines"]["types"], + line_types=config["lines"]["types"], release_version=config_provider("osm_network_release", "release_version"), include_polygons=True, export=True, @@ -108,7 +108,7 @@ rule map_incumbent: resources: mem_mb=1000, params: - line_types=config["transmission"]["electricity"]["lines"]["types"], + line_types=config["lines"]["types"], release_version="Incumbent", include_polygons=False, export=False, diff --git a/rules/postprocess.smk b/rules/postprocess.smk index b2c92b0814..eeb9e5a26c 100644 --- a/rules/postprocess.smk +++ b/rules/postprocess.smk @@ -62,9 +62,7 @@ if config["foresight"] != "perfect": mem_mb=10000, params: plotting=config_provider("plotting"), - transmission_limit=config_provider( - "transmission", "electricity", "transmission_limit" - ), + transmission_limit=config_provider("electricity", "transmission_limit"), message: "Plotting power network for {wildcards.clusters} clusters, {wildcards.opts} electric options, {wildcards.sector_opts} sector options and {wildcards.planning_horizons} planning horizons" script: diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 738b8e58a3..edc8c59a94 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -45,7 +45,6 @@ rule add_existing_baseyear: costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), - transmission=config_provider("transmission"), message: "Adding existing infrastructure for base year for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizons, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: @@ -83,7 +82,10 @@ rule add_brownfield: resources: mem_mb=10000, params: - transmission=config_provider("transmission"), + H2_retrofit=config_provider("sector", "H2_retrofit"), + H2_retrofit_capacity_per_CH4=config_provider( + "sector", "H2_retrofit_capacity_per_CH4" + ), threshold_capacity=config_provider("existing_capacities", "threshold_capacity"), snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), @@ -139,7 +141,6 @@ rule solve_sector_network_myopic: params: solving=config_provider("solving"), foresight=config_provider("foresight"), - transmission=config_provider("transmission"), co2_sequestration_potential=config_provider( "sector", "co2_sequestration_potential", default=200 ), diff --git a/rules/solve_overnight.smk b/rules/solve_overnight.smk index 3e58eb827c..0733312440 100644 --- a/rules/solve_overnight.smk +++ b/rules/solve_overnight.smk @@ -40,7 +40,6 @@ rule solve_sector_network: params: solving=config_provider("solving"), foresight=config_provider("foresight"), - transmission=config_provider("transmission"), co2_sequestration_potential=config_provider( "sector", "co2_sequestration_potential", default=200 ), diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index 389fc6f1b0..be21e6ffcc 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -45,7 +45,6 @@ rule add_existing_baseyear: costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), energy_totals_year=config_provider("energy", "energy_totals_year"), - transmission=config_provider("transmission"), message: "Adding existing infrastructure for base year for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizons, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" script: @@ -126,7 +125,6 @@ rule solve_sector_network_perfect: params: solving=config_provider("solving"), foresight=config_provider("foresight"), - transmission=config_provider("transmission"), sector=config_provider("sector"), planning_horizons=config_provider("scenario", "planning_horizons"), co2_sequestration_potential=config_provider( diff --git a/scripts/_helpers.py b/scripts/_helpers.py index 16ebaded04..4c74e4b718 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -750,7 +750,7 @@ def update_config_from_wildcards(config, w, inplace=True): for o in opts: if o.startswith("lv") or o.startswith("lc"): - config["transmission"]["electricity"]["transmission_limit"] = o[1:] + config["electricity"]["transmission_limit"] = o[1:] break if w.get("sector_opts"): @@ -792,10 +792,10 @@ def update_config_from_wildcards(config, w, inplace=True): config["clustering"]["temporal"]["resolution_sector"] = nhours if "decentral" in opts: - config["transmission"]["electricity"]["enable"] = False + config["sector"]["electricity_transmission_grid"] = False if "noH2network" in opts: - config["transmission"]["hydrogen"] = False + config["sector"]["H2_network"] = False if "nowasteheat" in opts: config["sector"]["use_fischer_tropsch_waste_heat"] = False @@ -810,9 +810,9 @@ def update_config_from_wildcards(config, w, inplace=True): dg_enable, dg_factor = find_opt(opts, "dist") if dg_enable: - config["transmission"]["electricity_distribution"]["enable"] = True + config["sector"]["electricity_distribution_grid"] = True if dg_factor is not None: - config["transmission"]["electricity_distribution"]["cost_factor"] = ( + config["sector"]["electricity_distribution_grid_cost_factor"] = ( dg_factor ) @@ -821,12 +821,8 @@ def update_config_from_wildcards(config, w, inplace=True): _, maxext = find_opt(opts, "linemaxext") if maxext is not None: - config["transmission"]["electricity"]["lines"]["max_extension"] = ( - maxext * 1e3 - ) - config["transmission"]["electricity"]["links"]["max_extension"] = ( - maxext * 1e3 - ) + config["lines"]["max_extension"] = maxext * 1e3 + config["links"]["max_extension"] = maxext * 1e3 _, co2l_value = find_opt(opts, "Co2L") if co2l_value is not None: diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 233412d2ae..d1a28bb45b 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -30,7 +30,8 @@ def add_brownfield( n, n_p, year, - cf_transmission=None, + h2_retrofit=False, + h2_retrofit_capacity_per_ch4=None, capacity_threshold=None, ): """ @@ -44,16 +45,14 @@ def add_brownfield( Previous network to get brownfield from year : int Planning year - cf_transmission : dict - Transmission configuration containing hydrogen retrofit settings + h2_retrofit : bool + Whether to allow hydrogen pipeline retrofitting + h2_retrofit_capacity_per_ch4 : float + Ratio of hydrogen to methane capacity for pipeline retrofitting capacity_threshold : float Threshold for removing assets with low capacity """ logger.info(f"Preparing brownfield for the year {year}") - hydrogen_retrofit = cf_transmission["hydrogen"]["retrofit"]["enable"] - hydrogen_retrofit_capacity_per_ch4 = cf_transmission["hydrogen"]["retrofit"][ - "capacity_per_ch4" - ] # electric transmission grid set optimised capacities of previous as minimum n.lines.s_nom_min = n_p.lines.s_nom_opt @@ -128,7 +127,7 @@ def add_brownfield( n._import_series_from_df(c.dynamic[tattr], c.name, tattr) # deal with gas network - if hydrogen_retrofit: + if h2_retrofit: # subtract the already retrofitted from the maximum capacity h2_retrofitted_fixed_i = n.links[ (n.links.carrier == "H2 pipeline retrofitted") @@ -162,7 +161,7 @@ def add_brownfield( pipe_capacity = n.links.loc[gas_pipes_i, "p_nom"] fr = "H2 pipeline retrofitted" to = "gas pipeline" - CH4_per_H2 = 1 / hydrogen_retrofit_capacity_per_ch4 + CH4_per_H2 = 1 / h2_retrofit_capacity_per_ch4 already_retrofitted.index = already_retrofitted.index.str.replace(fr, to) remaining_capacity = ( pipe_capacity @@ -382,7 +381,8 @@ def update_dynamic_ptes_capacity( n, n_p, year, - cf_transmission=snakemake.params.transmission, + h2_retrofit=snakemake.params.H2_retrofit, + h2_retrofit_capacity_per_ch4=snakemake.params.H2_retrofit_capacity_per_CH4, capacity_threshold=snakemake.params.threshold_capacity, ) diff --git a/scripts/add_electricity.py b/scripts/add_electricity.py index 79a06bd93a..84fd226dfd 100755 --- a/scripts/add_electricity.py +++ b/scripts/add_electricity.py @@ -14,9 +14,8 @@ ``networks/base_s_{clusters}_elec.nc``. It includes: - today's transmission topology and transfer capacities (optionally including - lines which are under construction according to the config settings - ``transmission: lines: under_construction`` and - ``links: under_construction``), + lines which are under construction according to the config settings ``lines: + under_construction`` and ``links: under_construction``), - today's thermal and hydro power generation capacities (for the technologies listed in the config setting ``electricity: conventional_carriers``), and - today's load time-series (upsampled in a top-down approach according to diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index bf8d3d27b1..7e03eccb9d 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -779,16 +779,15 @@ def add_heating_capacities_installed_before_baseyear( update_config_from_wildcards(snakemake.config, snakemake.wildcards) options = snakemake.params.sector + renewable_carriers = snakemake.params.carriers + baseyear = snakemake.params.baseyear - cf_transmission = snakemake.params.transmission n = pypsa.Network(snakemake.input.network) # define spatial resolution of carriers - spatial = define_spatial( - n.buses[n.buses.carrier == "AC"].index, options, cf_transmission - ) + spatial = define_spatial(n.buses[n.buses.carrier == "AC"].index, options) add_build_year_to_new_assets(n, baseyear) costs = load_costs(snakemake.input.costs) @@ -834,9 +833,7 @@ def add_heating_capacities_installed_before_baseyear( capacity_threshold=snakemake.params.existing_capacities[ "threshold_capacity" ], - use_electricity_distribution_grid=cf_transmission[ - "electricity_distribution" - ]["enable"], + use_electricity_distribution_grid=options["electricity_distribution_grid"], ) # Set defaults for missing missing values diff --git a/scripts/add_electricity_transmission_projects_and_dlr.py b/scripts/add_transmission_projects_and_dlr.py similarity index 97% rename from scripts/add_electricity_transmission_projects_and_dlr.py rename to scripts/add_transmission_projects_and_dlr.py index 36a1b0ac51..234dacf69d 100644 --- a/scripts/add_electricity_transmission_projects_and_dlr.py +++ b/scripts/add_transmission_projects_and_dlr.py @@ -73,7 +73,7 @@ def attach_line_rating( if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake("add_electricity_transmission_projects_and_dlr") + snakemake = mock_snakemake("add_transmission_projects_and_dlr") configure_logging(snakemake) # pylint: disable=E0606 set_scenario_config(snakemake) diff --git a/scripts/base_network.py b/scripts/base_network.py index e3602e261d..327a14df21 100644 --- a/scripts/base_network.py +++ b/scripts/base_network.py @@ -302,12 +302,12 @@ def _reconnect_crimea(lines): def _set_electrical_parameters_lines_eg(lines, config): v_noms = config["electricity"]["voltages"] - linetypes = config["transmission"]["electricity"]["lines"]["types"] + linetypes = config["lines"]["types"] for v_nom in v_noms: lines.loc[lines["v_nom"] == v_nom, "type"] = linetypes[v_nom] - lines["s_max_pu"] = config["transmission"]["electricity"]["lines"]["s_max_pu"] + lines["s_max_pu"] = config["lines"]["s_max_pu"] return lines @@ -318,9 +318,7 @@ def _set_electrical_parameters_lines_raw(lines, config): return lines v_noms = config["electricity"]["voltages"] - linetypes = _get_linetypes_config( - config["transmission"]["electricity"]["lines"]["types"], v_noms - ) + linetypes = _get_linetypes_config(config["lines"]["types"], v_noms) lines["carrier"] = "AC" lines["dc"] = False @@ -329,7 +327,7 @@ def _set_electrical_parameters_lines_raw(lines, config): lambda x: _get_linetype_by_voltage(x, linetypes) ) - lines["s_max_pu"] = config["transmission"]["electricity"]["lines"]["s_max_pu"] + lines["s_max_pu"] = config["lines"]["s_max_pu"] return lines @@ -347,8 +345,8 @@ def _set_electrical_parameters_links_eg(links, config, links_p_nom): if links.empty: return links - p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) - p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["links"].get("p_max_pu", 1.0) + p_min_pu = config["links"].get("p_min_pu", -p_max_pu) links["p_max_pu"] = p_max_pu links["p_min_pu"] = p_min_pu @@ -380,8 +378,8 @@ def _set_electrical_parameters_links_raw(links, config): if links.empty: return links - p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) - p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["links"].get("p_max_pu", 1.0) + p_min_pu = config["links"].get("p_min_pu", -p_max_pu) links["p_max_pu"] = p_max_pu links["p_min_pu"] = p_min_pu links["carrier"] = "DC" @@ -391,8 +389,8 @@ def _set_electrical_parameters_links_raw(links, config): def _set_electrical_parameters_converters(converters, config): - p_max_pu = config["transmission"]["electricity"]["links"].get("p_max_pu", 1.0) - p_min_pu = config["transmission"]["electricity"]["links"].get("p_min_pu", -p_max_pu) + p_max_pu = config["links"].get("p_max_pu", 1.0) + p_min_pu = config["links"].get("p_min_pu", -p_max_pu) converters["p_max_pu"] = p_max_pu converters["p_min_pu"] = p_min_pu @@ -409,7 +407,7 @@ def _set_electrical_parameters_converters(converters, config): def _set_electrical_parameters_transformers(transformers, config): - config = config["transmission"]["electricity"]["transformers"] + config = config["transformers"] ## Add transformer parameters transformers["x"] = config.get("x", 0.1) @@ -618,9 +616,7 @@ def _set_links_underwater_fraction(n, offshore_shapes): def _adjust_capacities_of_under_construction_branches(n, config): - lines_mode = config["transmission"]["electricity"]["lines"].get( - "under_construction", "undef" - ) + lines_mode = config["lines"].get("under_construction", "undef") if lines_mode == "zero": n.lines.loc[n.lines.under_construction, "num_parallel"] = 0.0 n.lines.loc[n.lines.under_construction, "s_nom"] = 0.0 @@ -628,19 +624,17 @@ def _adjust_capacities_of_under_construction_branches(n, config): n.remove("Line", n.lines.index[n.lines.under_construction]) elif lines_mode != "keep": logger.warning( - "Unrecognized configuration for `transmission: lines: under_construction` = `{}`. Keeping under construction lines." + "Unrecognized configuration for `lines: under_construction` = `{}`. Keeping under construction lines." ) - links_mode = config["transmission"]["electricity"]["links"].get( - "under_construction", "undef" - ) + links_mode = config["links"].get("under_construction", "undef") if links_mode == "zero": n.links.loc[n.links.under_construction, "p_nom"] = 0.0 elif links_mode == "remove": n.remove("Link", n.links.index[n.links.under_construction]) elif links_mode != "keep": logger.warning( - "Unrecognized configuration for `transmission: links: under_construction` = `{}`. Keeping under construction links." + "Unrecognized configuration for `links: under_construction` = `{}`. Keeping under construction links." ) if lines_mode == "remove" or links_mode == "remove": @@ -680,7 +674,7 @@ def base_network( parameter_corrections, config, ): - base_network = config["transmission"]["electricity"].get("base_network") + base_network = config["electricity"].get("base_network") osm_version = config["data"]["osm"]["version"] assert base_network in {"entsoegridkit", "osm", "tyndp"}, ( f"base_network must be either 'entsoegridkit', 'osm' or 'tyndp', but got '{base_network}'" @@ -707,9 +701,9 @@ def base_network( converters = _load_converters_from_eg(buses, converters) # Optionally reconnect Crimea - if ( - config["transmission"]["electricity"]["lines"].get("reconnect_crimea", True) - ) & ("UA" in config["countries"]): + if (config["lines"].get("reconnect_crimea", True)) & ( + "UA" in config["countries"] + ): lines = _reconnect_crimea(lines) # Set electrical parameters of lines and links @@ -748,7 +742,7 @@ def base_network( n.add("Link", converters.index, **converters) _set_lines_s_nom_from_linetypes(n) - if config["transmission"]["electricity"].get("base_network") == "entsoegridkit": + if config["electricity"].get("base_network") == "entsoegridkit": _apply_parameter_corrections(n, parameter_corrections) n = _remove_unconnected_components(n) diff --git a/scripts/build_transmission_delaunay_graph.py b/scripts/build_transmission_delaunay_graph.py index 50d49310a9..79674bf740 100644 --- a/scripts/build_transmission_delaunay_graph.py +++ b/scripts/build_transmission_delaunay_graph.py @@ -11,7 +11,7 @@ enriched with Gabriel edge flags and offshore underwater fractions. The output GeoDataFrame is consumed by build_transmission_topology to filter -candidate edges per carrier configuration. +candidate edges. Outputs ------- @@ -288,15 +288,10 @@ def add_underwater_fraction( if __name__ == "__main__": - is_mock_run = "snakemake" not in globals() - - if is_mock_run: + if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake( - "build_transmission_delaunay_graph", - clusters="50", - ) + snakemake = mock_snakemake("build_transmission_delaunay_graph", clusters="50") configure_logging(snakemake) set_scenario_config(snakemake) diff --git a/scripts/build_electricity_transmission_projects.py b/scripts/build_transmission_projects.py similarity index 96% rename from scripts/build_electricity_transmission_projects.py rename to scripts/build_transmission_projects.py index 35f7550949..e5c540c710 100644 --- a/scripts/build_electricity_transmission_projects.py +++ b/scripts/build_transmission_projects.py @@ -18,11 +18,11 @@ Outputs ------- -- ``transmission/electricity_projects/new_lines.csv``: New project lines to be added to the network. This includes new lines and upgraded lines. -- ``transmission/electricity_projects/new_links.csv``: New project links to be added to the network. This includes new links and upgraded links. -- ``transmission/electricity_projects/adjust_lines.csv``: For lines which are upgraded, the decommissioning year of the existing line is adjusted to the build year of the upgraded line. -- ``transmission/electricity_projects/adjust_links.csv``: For links which are upgraded, the decommissioning year of the existing link is adjusted to the build year of the upgraded link. -- ``transmission/electricity_projects/new_buses.csv``: For some links, we have to add new buses (e.g. North Sea Wind Power Hub). +- ``transmission_projects/new_lines.csv``: New project lines to be added to the network. This includes new lines and upgraded lines. +- ``transmission_projects/new_links.csv``: New project links to be added to the network. This includes new links and upgraded links. +- ``transmission_projects/adjust_lines.csv``: For lines which are upgraded, the decommissioning year of the existing line is adjusted to the build year of the upgraded line. +- ``transmission_projects/adjust_links.csv``: For links which are upgraded, the decommissioning year of the existing link is adjusted to the build year of the upgraded link. +- ``transmission_projects/new_buses.csv``: For some links, we have to add new buses (e.g. North Sea Wind Power Hub). """ import logging @@ -467,7 +467,7 @@ def fill_length_from_geometry(line, line_factor=1.2): if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake("build_electricity_transmission_projects") + snakemake = mock_snakemake("build_transmission_projects") configure_logging(snakemake) set_scenario_config(snakemake) diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index e9de383726..c1e9435adf 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -9,7 +9,7 @@ ----------- Reads the full Delaunay edge table produced by build_transmission_delaunay_graph and applies Gabriel graph filtering and min-degree backfilling to produce a -candidate edge set for one (min_degree, max_offshore_haversine_distance) pair. +candidate edge set for the H2 and CO2 pipeline networks. Outputs ------- @@ -31,6 +31,7 @@ import geopandas as gpd import numpy as np +import pandas as pd from scripts._helpers import configure_logging, set_scenario_config @@ -94,8 +95,6 @@ def enforce_min_degree( ) return delaunay_graph.copy() - import pandas as pd - result = delaunay_graph[delaunay_graph["gabriel_edge"]].copy() all_sorted = delaunay_graph.sort_values("length").reset_index(drop=True) source_arr = all_sorted["source"].to_numpy(dtype=int) @@ -191,24 +190,16 @@ def filter_by_max_offshore_haversine_distance( if __name__ == "__main__": - is_mock_run = "snakemake" not in globals() - - if is_mock_run: + if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake( - "build_transmission_topology", - clusters="50", - min_degree=1, - max_offdist="inf", - configfiles=["config/config.200.yaml"], - ) + snakemake = mock_snakemake("build_transmission_topology", clusters="50") configure_logging(snakemake) set_scenario_config(snakemake) - min_degree = snakemake.params["min_degree"] - max_offdist = snakemake.params["max_offdist"] + min_degree = snakemake.params.pipeline_topology["min_degree"] + max_offdist = snakemake.params.pipeline_topology["max_offshore_distance"] delaunay_graph = gpd.read_file(snakemake.input.delaunay_graph) diff --git a/scripts/lib/validation/config/_schema.py b/scripts/lib/validation/config/_schema.py index 471656bb5a..5adb8665f0 100644 --- a/scripts/lib/validation/config/_schema.py +++ b/scripts/lib/validation/config/_schema.py @@ -23,6 +23,8 @@ from scripts.lib.validation.config.existing_capacities import ExistingCapacitiesConfig from scripts.lib.validation.config.foresight import ForesightConfig from scripts.lib.validation.config.industry import IndustryConfig +from scripts.lib.validation.config.lines import LinesConfig +from scripts.lib.validation.config.links import LinksConfig from scripts.lib.validation.config.load import LoadConfig from scripts.lib.validation.config.overpass_api import OverpassApiConfig from scripts.lib.validation.config.pypsa_eur import PypsaEurConfig @@ -33,7 +35,10 @@ from scripts.lib.validation.config.snapshots import SnapshotsConfig from scripts.lib.validation.config.solar_thermal import SolarThermalConfig from scripts.lib.validation.config.solving import SolvingConfig -from scripts.lib.validation.config.transmission import TransmissionConfig +from scripts.lib.validation.config.transformers import TransformersConfig +from scripts.lib.validation.config.transmission_projects import ( + TransmissionProjectsConfig, +) class LoggingConfig(ConfigModel): @@ -127,10 +132,6 @@ class ConfigSchema(BaseModel): default_factory=ElectricityConfig, description="Electricity sector configuration.", ) - transmission: TransmissionConfig = Field( - default_factory=TransmissionConfig, - description="Transmission candidate configuration.", - ) atlite: AtliteConfig = Field( default_factory=AtliteConfig, description="Atlite cutout configuration for weather data.", @@ -143,6 +144,22 @@ class ConfigSchema(BaseModel): default_factory=ConventionalConfig, description="Conventional power plants configuration.", ) + lines: LinesConfig = Field( + default_factory=LinesConfig, + description="Transmission lines configuration.", + ) + links: LinksConfig = Field( + default_factory=LinksConfig, + description="HVDC links configuration.", + ) + transmission_projects: TransmissionProjectsConfig = Field( + default_factory=TransmissionProjectsConfig, + description="Transmission projects configuration.", + ) + transformers: TransformersConfig = Field( + default_factory=TransformersConfig, + description="Transformers configuration.", + ) load: LoadConfig = Field( default_factory=LoadConfig, description="Electrical load configuration.", diff --git a/scripts/lib/validation/config/electricity.py b/scripts/lib/validation/config/electricity.py index 8c92e554c6..d343cb06cd 100644 --- a/scripts/lib/validation/config/electricity.py +++ b/scripts/lib/validation/config/electricity.py @@ -8,6 +8,8 @@ See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#electricity """ +from typing import Literal + from pydantic import BaseModel, ConfigDict, Field from scripts.lib.validation.config._base import ConfigModel @@ -169,6 +171,10 @@ class ElectricityConfig(BaseModel): default_factory=lambda: [220.0, 300.0, 330.0, 380.0, 400.0, 500.0, 750.0], description="Voltage levels to consider.", ) + base_network: Literal["entsoegridkit", "osm", "tyndp"] = Field( + "osm", + description="Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", + ) gaslimit_enable: bool = Field( False, description="Add an overall absolute gas limit configured in `electricity: gaslimit`.", @@ -251,5 +257,9 @@ class ElectricityConfig(BaseModel): default_factory=_AutarkyConfig, description="Autarky configuration.", ) + transmission_limit: str = Field( + "vopt", + description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", + ) model_config = ConfigDict(populate_by_name=True) diff --git a/scripts/lib/validation/config/lines.py b/scripts/lib/validation/config/lines.py new file mode 100644 index 0000000000..7ef71cc85c --- /dev/null +++ b/scripts/lib/validation/config/lines.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Lines configuration. + +See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#lines +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from scripts.lib.validation.config._base import ConfigModel + + +class _DynamicLineRatingConfig(ConfigModel): + """Configuration for `lines.dynamic_line_rating` settings.""" + + activate: bool = Field( + False, + description="Whether to take dynamic line rating into account.", + ) + cutout: str | list[str] = Field( + "default", + description="Specifies the weather data cutout file(s) to use.", + ) + correction_factor: float = Field( + 0.95, + description="Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", + ) + max_voltage_difference: float | Literal[False] = Field( + False, + description="Maximum voltage angle difference in degrees or 'false' to disable.", + ) + max_line_rating: float | Literal[False] = Field( + False, + description="Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable.", + ) + + +class LinesConfig(BaseModel): + """Configuration for `lines` settings.""" + + types: dict[float, str] = Field( + default_factory=lambda: { + 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", + }, + description="Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", + ) + s_max_pu: float = Field( + 0.7, + description="Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", + ) + s_nom_max: float = Field( + float("inf"), + description="Global upper limit for the maximum capacity of each extendable line (MW).", + ) + max_extension: float = Field( + 20000, + description="Upper limit for the extended capacity of each extendable line (MW).", + ) + length_factor: float = Field( + 1.25, + description="Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", + ) + reconnect_crimea: bool = Field( + True, + description="Whether to reconnect Crimea to the Ukrainian grid.", + ) + under_construction: Literal["zero", "remove", "keep"] = Field( + "keep", + description="Specifies how to handle lines which are currently under construction.", + ) + dynamic_line_rating: _DynamicLineRatingConfig = Field( + default_factory=_DynamicLineRatingConfig, + description="Configuration for dynamic line rating.", + ) diff --git a/scripts/lib/validation/config/links.py b/scripts/lib/validation/config/links.py new file mode 100644 index 0000000000..a994c20af6 --- /dev/null +++ b/scripts/lib/validation/config/links.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Links configuration. + +See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#links +""" + +from typing import Literal + +from pydantic import Field + +from scripts.lib.validation.config._base import ConfigModel + + +class LinksConfig(ConfigModel): + """Configuration for `links` settings.""" + + p_max_pu: float = Field( + 1.0, + description="Correction factor for link capacities `p_nom`.", + ) + p_min_pu: float = Field( + -1.0, + description="Correction factor for link capacities `p_nom`.", + ) + p_nom_max: float = Field( + float("inf"), + description="Global upper limit for the maximum capacity of each extendable DC link (MW).", + ) + max_extension: float = Field( + 30000, + description="Upper limit for the extended capacity of each extendable DC link (MW).", + ) + length_factor: float = Field( + 1.25, + description="Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", + ) + under_construction: Literal["zero", "remove", "keep"] = Field( + "keep", + description="Specifies how to handle lines which are currently under construction.", + ) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index a546d05d1c..6f14ceb21b 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -10,7 +10,7 @@ from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from scripts.lib.validation.config._base import ConfigModel @@ -224,6 +224,50 @@ class _MethanolConfig(BaseModel): ) +class _TransmissionEfficiencyConfig(BaseModel): + """Configuration for `sector.transmission_efficiency` settings.""" + + enable: list[str] = Field( + default_factory=lambda: [ + "DC", + "H2 pipeline", + "gas pipeline", + "electricity distribution grid", + ], + description="Switch to select the carriers for which transmission efficiency is to be added. Carriers not listed assume lossless transmission.", + ) + DC: dict[str, float] = Field( + default_factory=lambda: { + "efficiency_static": 0.98, + "efficiency_per_1000km": 0.977, + }, + description="DC transmission efficiency.", + ) + H2_pipeline: dict[str, float] = Field( + default_factory=lambda: { + "efficiency_per_1000km": 1, + "compression_per_1000km": 0.018, + }, + alias="H2 pipeline", + description="H2 pipeline transmission efficiency.", + ) + gas_pipeline: dict[str, float] = Field( + default_factory=lambda: { + "efficiency_per_1000km": 1, + "compression_per_1000km": 0.01, + }, + alias="gas pipeline", + description="Gas pipeline transmission efficiency.", + ) + electricity_distribution_grid: dict[str, float] = Field( + default_factory=lambda: {"efficiency_static": 0.97}, + alias="electricity distribution grid", + description="Electricity distribution grid efficiency.", + ) + + model_config = ConfigDict(populate_by_name=True) + + class _LimitMaxGrowthConfig(BaseModel): """Configuration for `sector.limit_max_growth` settings.""" @@ -297,6 +341,21 @@ class _SolidBiomassImportConfig(BaseModel): ) +class _PipelineTopologyConfig(BaseModel): + """Configuration for `sector.pipeline_topology` settings.""" + + min_degree: int = Field( + 1, + ge=0, + description="Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + ) + max_offshore_distance: float = Field( + float("inf"), + gt=0, + description="Maximum offshore length of a candidate pipeline (km).", + ) + + class _ImportsConfig(BaseModel): """Configuration for `sector.imports` settings.""" @@ -696,6 +755,14 @@ class SectorConfig(BaseModel): True, description="Add option to spatially resolve carrier representing stored carbon dioxide. This allows for more detailed modelling of CCUTS, e.g. regarding the capturing of industrial process emissions, usage as feedstock for electrofuels, transport of carbon dioxide, and geological sequestration sites.", ) + co2_network: bool = Field( + True, + description="Add option for planning a new carbon dioxide transmission network.", + ) + co2_network_cost_factor: float = Field( + 1, + description="The cost factor for the capital cost of the carbon dioxide transmission network.", + ) cc_fraction: float = Field( 0.9, description="The default fraction of CO2 captured with post-combustion capture.", @@ -755,13 +822,51 @@ class SectorConfig(BaseModel): description="Add option for using waste heat of electrolysis in district heating networks.", ) - electricity_grid_connection_cost: bool = Field( + electricity_transmission_grid: bool = Field( + True, + description="Switch for enabling/disabling the electricity transmission grid.", + ) + electricity_distribution_grid: bool = Field( + True, + description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", + ) + electricity_distribution_grid_cost_factor: float = Field( + 1.0, + description="Multiplies the investment cost of the electricity distribution grid.", + ) + electricity_grid_connection: bool = Field( True, description="Add the cost of electricity grid connection for onshore wind and solar.", ) - gas_distribution_grid_cost: bool = Field( + + transmission_efficiency: _TransmissionEfficiencyConfig = Field( + default_factory=_TransmissionEfficiencyConfig, + description="Transmission efficiency configuration.", + ) + + H2_network: bool = Field(True, description="Add option for new hydrogen pipelines.") + pipeline_topology: _PipelineTopologyConfig = Field( + default_factory=_PipelineTopologyConfig, + description="Candidate corridors for new hydrogen and carbon dioxide pipelines, derived from the Delaunay triangulation of the regions.", + ) + gas_network: bool = Field( True, - description="Add gas distribution grid costs to gas-consuming components.", + description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. The existing gas network is added with a lossless transport model. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", + ) + H2_retrofit: bool = Field( + False, + description="Add option for retrofiting existing pipelines to transport hydrogen.", + ) + H2_retrofit_capacity_per_CH4: float = Field( + 0.6, + description="The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", + ) + gas_network_connectivity_upgrade: float = Field( + 1, + description="The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", + ) + gas_distribution_grid: bool = Field( + True, description="Add a gas distribution grid." ) gas_distribution_grid_cost_factor: float = Field( 1.0, diff --git a/scripts/lib/validation/config/transformers.py b/scripts/lib/validation/config/transformers.py new file mode 100644 index 0000000000..207981250f --- /dev/null +++ b/scripts/lib/validation/config/transformers.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Transformers configuration. + +See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transformers +""" + +from pydantic import Field + +from scripts.lib.validation.config._base import ConfigModel + + +class TransformersConfig(ConfigModel): + """Configuration for `transformers` settings.""" + + x: float = Field( + 0.1, + description="Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", + ) + s_nom: float = Field( + 2000.0, + description="Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", + ) + type: str = Field( + "", + description="Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", + ) diff --git a/scripts/lib/validation/config/transmission.py b/scripts/lib/validation/config/transmission.py deleted file mode 100644 index 5b1b8dee39..0000000000 --- a/scripts/lib/validation/config/transmission.py +++ /dev/null @@ -1,375 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Transmission candidate configuration. - -See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission -""" - -from typing import Literal - -from pydantic import BaseModel, Field - -from scripts.lib.validation.config._base import ConfigModel - - -class _DynamicLineRatingConfig(ConfigModel): - """Configuration for `lines.dynamic_line_rating` settings.""" - - activate: bool = Field( - False, - description="Whether to take dynamic line rating into account.", - ) - cutout: str | list[str] = Field( - "default", - description="Specifies the weather data cutout file(s) to use.", - ) - correction_factor: float = Field( - 0.95, - description="Factor to compensate for overestimation of wind speeds in hourly averaged wind data.", - ) - max_voltage_difference: float | Literal[False] = Field( - False, - description="Maximum voltage angle difference in degrees or 'false' to disable.", - ) - max_line_rating: float | Literal[False] = Field( - False, - description="Maximum line rating relative to nominal capacity without DLR, e.g. 1.3 or 'false' to disable.", - ) - - -class LinesConfig(BaseModel): - """Configuration for `lines` settings.""" - - types: dict[float, str] = Field( - default_factory=lambda: { - 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", - }, - description="Specifies line types to assume for the different voltage levels of the ENTSO-E grid extraction. Should normally handle voltage levels 220, 300, and 380 kV.", - ) - s_max_pu: float = Field( - 0.7, - description="Correction factor for line capacities (`s_nom`) to approximate N-1 security and reserve capacity for reactive power flows.", - ) - s_nom_max: float = Field( - float("inf"), - description="Global upper limit for the maximum capacity of each extendable line (MW).", - ) - max_extension: float = Field( - 20000, - description="Upper limit for the extended capacity of each extendable line (MW).", - ) - length_factor: float = Field( - 1.25, - description="Correction factor to account for the fact that buses are *not* connected by lines through air-line distance.", - ) - reconnect_crimea: bool = Field( - True, - description="Whether to reconnect Crimea to the Ukrainian grid.", - ) - under_construction: Literal["zero", "remove", "keep"] = Field( - "keep", - description="Specifies how to handle lines which are currently under construction.", - ) - dynamic_line_rating: _DynamicLineRatingConfig = Field( - default_factory=_DynamicLineRatingConfig, - description="Configuration for dynamic line rating.", - ) - - -class LinksConfig(ConfigModel): - """Configuration for `links` settings.""" - - class _DCEfficiencyConfig(BaseModel): - """Configuration for `transmission.electricity.links.efficiency` settings.""" - - enable: bool = Field( - True, - description="Apply transmission efficiency for DC links.", - ) - efficiency_static: float = Field( - 0.98, - description="Static DC transmission efficiency.", - ) - efficiency_per_1000km: float = Field( - 0.977, - description="Distance-dependent DC efficiency factor per 1000 km.", - ) - - p_max_pu: float = Field( - 1.0, - description="Correction factor for link capacities `p_nom`.", - ) - p_min_pu: float = Field( - -1.0, - description="Correction factor for link capacities `p_nom`.", - ) - p_nom_max: float = Field( - float("inf"), - description="Global upper limit for the maximum capacity of each extendable DC link (MW).", - ) - max_extension: float = Field( - 30000, - description="Upper limit for the extended capacity of each extendable DC link (MW).", - ) - length_factor: float = Field( - 1.25, - description="Correction factor to account for the fact that buses are *not* connected by links through air-line distance.", - ) - under_construction: Literal["zero", "remove", "keep"] = Field( - "keep", - description="Specifies how to handle lines which are currently under construction.", - ) - efficiency: _DCEfficiencyConfig = Field( - default_factory=_DCEfficiencyConfig, - description="DC transmission efficiency configuration.", - ) - - -class TransformersConfig(ConfigModel): - """Configuration for `transformers` settings.""" - - x: float = Field( - 0.1, - description="Series reactance in per unit (p.u.), using `s_nom` as base power of the transformer. Overwritten if `type` is specified.", - ) - s_nom: float = Field( - 2000.0, - description="Limit of apparent power which can pass through branch (MVA). Overwritten if `type` is specified.", - ) - type: str = Field( - "", - description="Specifies transformer types to assume for the transformers of the ENTSO-E grid extraction.", - ) - - -class _IncludeConfig(ConfigModel): - """Configuration for `electricity.projects.include` settings.""" - - tyndp2020: bool = Field( - True, - description="Whether to integrate the TYNDP 2020 dataset.", - ) - nep: bool = Field( - True, - description="Whether to integrate the German network development plan dataset.", - ) - manual: bool = Field( - True, - description="Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", - ) - - -class ElectricityProjectsConfig(BaseModel): - """Configuration for `electricity.projects` settings.""" - - enable: bool = Field( - True, - description="Whether to integrate this transmission projects or not.", - ) - include: _IncludeConfig = Field( - default_factory=_IncludeConfig, - description="Name of the transmission projects. They should be unique and have to be provided in the `data/transmission_projects` folder.", - ) - skip: list[str] = Field( - default_factory=lambda: ["upgraded_lines", "upgraded_links"], - description="Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", - ) - status: list[str] | dict[str, list[str]] = Field( - default_factory=lambda: ["under_construction", "in_permitting", "confirmed"], - description="Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`.", - ) - new_link_capacity: Literal["zero", "keep"] = Field( - "zero", - description="Whether to set the new link capacity to the provided capacity or set it to zero.", - ) - - -class _TransmissionCarrierConfigGeneral(BaseModel): - """Configuration for a single transmission carrier.""" - - enable: bool = Field( - True, - description="Enable transmission candidate generation for this carrier.", - ) - gabriel_filter_min_degree: int = Field( - 1, - ge=0, - description="Minimum node degree target applied after Gabriel graph filtering. Set to >= 1 to activate Gabriel filtering with min-degree backfilling; 0 disables it.", - ) - 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).", - ) - length_factor: float = Field( - 1.25, - gt=0, - description="Multiplier applied to geometric corridor lengths.", - ) - cost_factor: float = Field( - 1, - gt=0, - description="Multiplier applied to the capital cost of transmission infrastructure.", - ) - - -class _TransmissionEfficiencyConfig(BaseModel): - """Configuration for `transmission..efficiency` settings.""" - - enable: bool = Field( - True, - description="Apply transmission efficiency for this carrier.", - ) - efficiency_per_1000km: float = Field( - 1, - description="Distance-dependent efficiency factor per 1000 km.", - ) - compression_per_1000km: float = Field( - 0, - description="Additional electricity consumption for compression per 1000 km.", - ) - - -class _HydrogenRetrofitConfig(BaseModel): - """Configuration for `transmission.hydrogen.retrofit` settings.""" - - enable: bool = Field( - False, - description="Add option for retrofitting existing pipelines to transport hydrogen.", - ) - capacity_per_ch4: float = Field( - 0.6, - description="The ratio for H2 capacity per original CH4 capacity of retrofitted pipelines. The `European Hydrogen Backbone (April, 2020) p.15 `_ 60% of original natural gas capacity could be used in cost-optimal case as H2 capacity.", - ) - - -class _TransmissionCarrierConfigElectricity(BaseModel): - """Configuration for electricity transmission grid.""" - - enable: bool = Field( - True, - description="Switch for enabling/disabling the electricity transmission grid.", - ) - base_network: Literal["entsoegridkit", "osm", "tyndp"] = Field( - "osm", - description="Specify the underlying base network, i.e. GridKit (based on ENTSO-E web map extract), OpenStreetMap (OSM), or TYNDP.", - ) - transmission_limit: str = Field( - "vopt", - description="Limit on transmission expansion. The first part can be `v` (for setting a limit on line volume) or `c` (for setting a limit on line cost). The second part can be `opt` or a float bigger than one (e.g. 1.25). If `opt` is chosen line expansion is optimised according to its capital cost (where the choice `v` only considers overhead costs for HVDC transmission lines, while `c` uses more accurate costs distinguishing between overhead and underwater sections and including inverter pairs). The setting `v1.25` will limit the total volume of line expansion to 25% of currently installed capacities weighted by individual line lengths. The setting `c1.25` will allow to build a transmission network that costs no more than 25 % more than the current system.", - ) - lines: LinesConfig = Field( - default_factory=LinesConfig, - description="Transmission lines configuration.", - ) - links: LinksConfig = Field( - default_factory=LinksConfig, - description="HVDC links configuration.", - ) - transformers: TransformersConfig = Field( - default_factory=TransformersConfig, - description="Transformers configuration.", - ) - projects: ElectricityProjectsConfig = Field( - default_factory=ElectricityProjectsConfig, - description="Electricity transmission projects configuration.", - ) - - -class _TransmissionCarrierConfigGas(BaseModel): - enable: bool = Field( - True, - description="Add existing natural gas infrastructure, incl. LNG terminals, production and entry-points. A length-weighted `k-edge augmentation algorithm `_ can be run to add new candidate gas pipelines such that all regions of the model can be connected to the gas network. When activated, all the gas demands are regionally disaggregated as well.", - ) - connectivity_upgrade: float = Field( - 1, - description="The number of desired edge connectivity (k) in the length-weighted `k-edge augmentation algorithm `_ used for the gas network.", - ) - efficiency: _TransmissionEfficiencyConfig = Field( - default_factory=lambda: _TransmissionEfficiencyConfig( - efficiency_per_1000km=1, - compression_per_1000km=0.01, - ), - description="Methane gas pipeline transmission efficiency.", - ) - - -class _TransmissionCarrierConfigElectricityDistribution(BaseModel): - class _ElectricityDistributionEfficiencyConfig(BaseModel): - """Configuration for `transmission.electricity_distribution.efficiency` settings.""" - - enable: bool = Field( - True, - description="Apply electricity distribution efficiency.", - ) - efficiency_static: float = Field( - 0.97, - description="Static electricity distribution efficiency.", - ) - - enable: bool = Field( - True, - description="Add a simplified representation of the exchange capacity between transmission and distribution grid level through a link.", - ) - cost_factor: float = Field( - 1.0, - description="Multiplies the investment cost of the electricity distribution grid.", - ) - efficiency: _ElectricityDistributionEfficiencyConfig = Field( - default_factory=_ElectricityDistributionEfficiencyConfig, - description="Electricity distribution efficiency configuration.", - ) - - -class _TransmissionCarrierConfigHydrogen(_TransmissionCarrierConfigGeneral): - retrofit: _HydrogenRetrofitConfig = Field( - default_factory=_HydrogenRetrofitConfig, - description="Hydrogen pipeline retrofit configuration.", - ) - efficiency: _TransmissionEfficiencyConfig = Field( - default_factory=lambda: _TransmissionEfficiencyConfig( - efficiency_per_1000km=1, - compression_per_1000km=0.018, - ), - description="Hydrogen pipeline transmission efficiency.", - ) - - -class TransmissionConfig(BaseModel): - """Configuration for `transmission` settings.""" - - electricity: _TransmissionCarrierConfigElectricity = Field( - default_factory=_TransmissionCarrierConfigElectricity, - description="Configuration for electricity transmission grid.", - ) - electricity_distribution: _TransmissionCarrierConfigElectricityDistribution = Field( - default_factory=_TransmissionCarrierConfigElectricityDistribution, - description="Configuration for simplified electricity distribution grid.", - ) - hydrogen: _TransmissionCarrierConfigHydrogen = Field( - default_factory=_TransmissionCarrierConfigHydrogen, - description="Configuration for hydrogen transmission candidates.", - ) - carbon_dioxide: _TransmissionCarrierConfigGeneral = Field( - default_factory=_TransmissionCarrierConfigGeneral, - description="Configuration for carbon dioxide transmission candidates.", - ) - gas: _TransmissionCarrierConfigGas = Field( - default_factory=_TransmissionCarrierConfigGas, - description="Configuration for methane gas transmission candidates.", - ) diff --git a/scripts/lib/validation/config/transmission_projects.py b/scripts/lib/validation/config/transmission_projects.py new file mode 100644 index 0000000000..e48a790a98 --- /dev/null +++ b/scripts/lib/validation/config/transmission_projects.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +Transmission projects configuration. + +See docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#transmission-projects +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from scripts.lib.validation.config._base import ConfigModel + + +class _IncludeConfig(ConfigModel): + """Configuration for `transmission_projects.include` settings.""" + + tyndp2020: bool = Field( + True, + description="Whether to integrate the TYNDP 2020 dataset.", + ) + nep: bool = Field( + True, + description="Whether to integrate the German network development plan dataset.", + ) + manual: bool = Field( + True, + description="Whether to integrate the manually added transmission projects. They are taken from the previously existing links_tyndp.csv file.", + ) + + +class TransmissionProjectsConfig(BaseModel): + """Configuration for `transmission_projects` settings.""" + + enable: bool = Field( + True, + description="Whether to integrate this transmission projects or not.", + ) + include: _IncludeConfig = Field( + default_factory=_IncludeConfig, + description="Name of the transmission projects. They should be unique and have to be provided in the `data/transmission_projects` folder.", + ) + skip: list[str] = Field( + default_factory=lambda: ["upgraded_lines", "upgraded_links"], + description="Type of lines to skip from all transmission projects. Possible values are: `upgraded_lines`, `upgraded_links`, `new_lines`, `new_links`.", + ) + status: list[str] | dict[str, list[str]] = Field( + default_factory=lambda: ["under_construction", "in_permitting", "confirmed"], + description="Status to include into the model as list or as dict with name of project and status to include. Possible values for status are `under_construction`, `in_permitting`, `confirmed`, `planned_not_yet_permitted`, `under_consideration`.", + ) + new_link_capacity: Literal["zero", "keep"] = Field( + "zero", + description="Whether to set the new link capacity to the provided capacity or set it to zero.", + ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index b832e39e5f..1a3bab67a2 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -51,7 +51,7 @@ logger = logging.getLogger(__name__) -def define_spatial(nodes, options, cf_transmission): +def define_spatial(nodes, options): """ Namespace for spatial. @@ -110,7 +110,7 @@ def define_spatial(nodes, options, cf_transmission): spatial.gas = SimpleNamespace() - if cf_transmission["gas"]["enable"]: + if options["gas_network"]: spatial.gas.nodes = nodes + " gas" spatial.gas.locations = nodes spatial.gas.biogas = nodes + " biogas" @@ -128,7 +128,7 @@ def define_spatial(nodes, options, cf_transmission): spatial.gas.biogas_to_gas_cc = nodes + " biogas to gas CC" else: spatial.gas.biogas_to_gas_cc = ["EU biogas to gas CC"] - if options.get("co2_spatial", cf_transmission["carbon_dioxide"]["enable"]): + if options.get("co2_spatial", options["co2_network"]): spatial.gas.industry_cc = nodes + " gas for industry CC" else: spatial.gas.industry_cc = ["gas for industry CC"] @@ -422,6 +422,27 @@ def make_index(c): return topo +def read_pipeline_candidates(fn: str, length_factor: float) -> pd.DataFrame: + """ + Read candidate pipeline corridors between locations. + + Parameters + ---------- + fn : str + Path to the candidate corridors from ``build_transmission_topology``. + length_factor : float + Factor applied to the great-circle lengths of the corridors. + + Returns + ------- + pd.DataFrame with index ``"bus0 -> bus1"`` and columns bus0, bus1, length, + underwater_fraction + """ + candidates = gpd.read_file(fn).set_index("name") + candidates["length"] *= length_factor + return pd.DataFrame(candidates[["bus0", "bus1", "length", "underwater_fraction"]]) + + def update_wind_solar_costs( n: pypsa.Network, costs: pd.DataFrame, @@ -872,13 +893,7 @@ def add_co2_tracking( ) -def add_co2_network( - n, - carbon_dioxide_transmission_candidates, - costs, - co2_transmission_cost_factor=1.0, - co2_transmission_length_factor=1.0, -): +def add_co2_network(n, costs, pipeline_candidates, co2_network_cost_factor=1.0): """ Add CO2 transport network to the PyPSA network. @@ -890,17 +905,14 @@ def add_co2_network( ---------- n : pypsa.Network The PyPSA network container object - carbon_dioxide_transmission_candidates : str - Path to GeoJSON file containing CO2 pipeline candidates costs : pd.DataFrame Cost assumptions for different technologies. Must contain entries for 'CO2 pipeline' and 'CO2 submarine pipeline' with 'capital_cost' and 'lifetime' columns - co2_transmission_cost_factor : float, optional + pipeline_candidates : pd.DataFrame + Candidate pipeline corridors from `read_pipeline_candidates` + co2_network_cost_factor : float, optional Factor to scale the capital costs of the CO2 network, default 1.0 - co2_transmission_length_factor : float, optional - Factor to scale transmission lengths read from topology candidates, - default 1.0 Returns ------- @@ -911,46 +923,35 @@ def add_co2_network( ----- The function creates bidirectional CO2 pipeline links between nodes, with costs depending on the underwater fraction of the pipeline. The network topology is - created using the create_network_topology helper function. + given by the candidate pipeline corridors. """ logger.info("Adding CO2 network.") - co2_links = gpd.read_file(carbon_dioxide_transmission_candidates).copy() - - if "name" not in co2_links.columns: - co2_links["name"] = co2_links["bus0"] + " -> " + co2_links["bus1"] - - if not co2_links.empty and not co2_links.iloc[0]["bus0"].endswith(" co2 stored"): - co2_links[["bus0", "bus1"]] = co2_links[["bus0", "bus1"]] + " co2 stored" - - co2_links = co2_links.set_index("name") - co2_links.index = "CO2 pipeline " + co2_links.index + co2_links = pipeline_candidates.add_prefix("CO2 pipeline ", axis=0) if "underwater_fraction" not in co2_links.columns: co2_links["underwater_fraction"] = 0.0 - scaled_length = co2_links["length"] * co2_transmission_length_factor - cost_onshore = ( (1 - co2_links.underwater_fraction) * costs.at["CO2 pipeline", "capital_cost"] - * scaled_length + * co2_links.length ) cost_submarine = ( co2_links.underwater_fraction * costs.at["CO2 submarine pipeline", "capital_cost"] - * scaled_length + * co2_links.length ) capital_cost = cost_onshore + cost_submarine - capital_cost *= co2_transmission_cost_factor + capital_cost *= co2_network_cost_factor n.add( "Link", co2_links.index, - bus0=co2_links["bus0"].values, - bus1=co2_links["bus1"].values, + bus0=co2_links.bus0.values + " co2 stored", + bus1=co2_links.bus1.values + " co2 stored", p_min_pu=-1, p_nom_extendable=True, - length=scaled_length.values, + length=co2_links.length.values, capital_cost=capital_cost.values, carrier="CO2 pipeline", lifetime=costs.at["CO2 pipeline", "lifetime"], @@ -1525,7 +1526,7 @@ def add_ammonia( def insert_electricity_distribution_grid( n: pypsa.Network, costs: pd.DataFrame, - cf_transmission: dict, + options: dict, pop_layout: pd.DataFrame, solar_rooftop_potentials_fn: str, ) -> None: @@ -1544,9 +1545,10 @@ def insert_electricity_distribution_grid( Technology cost assumptions with technologies as index and cost parameters as columns, including 'fixed' costs, 'lifetime', and component-specific parameters like 'efficiency' - cf_transmission : dict - Transmission configuration containing at least: - - electricity_distribution.efficiency: distribution grid loss parameters + options : dict + Configuration options containing at least: + - transmission_efficiency: dict with distribution grid parameters + - marginal_cost_storage: float for storage operation costs pop_layout : pd.DataFrame Population data per node with at least: - 'total' column containing population in thousands @@ -1597,10 +1599,13 @@ def insert_electricity_distribution_grid( # deduct distribution losses from electricity demand as these are included in total load # https://nbviewer.org/github/Open-Power-System-Data/datapackage_timeseries/blob/2020-10-06/main.ipynb - distribution_efficiency = cf_transmission["electricity_distribution"]["efficiency"] - if distribution_efficiency["enable"] and ( - efficiency := distribution_efficiency["efficiency_static"] - ): + if ( + efficiency := options["transmission_efficiency"] + .get("electricity distribution grid", {}) + .get("efficiency_static") + ) and "electricity distribution grid" in options["transmission_efficiency"][ + "enable" + ]: logger.info( f"Deducting distribution losses from electricity demand: {np.around(100 * (1 - efficiency), decimals=2)}%" ) @@ -1697,7 +1702,7 @@ def insert_electricity_distribution_grid( ) -def insert_gas_distribution_cost( +def insert_gas_distribution_costs( n: pypsa.Network, costs: pd.DataFrame, options: dict, @@ -1756,7 +1761,7 @@ def insert_gas_distribution_cost( n.links.loc[mchp, "capital_cost"] += capital_cost -def add_electricity_grid_connection_cost(n, costs): +def add_electricity_grid_connection(n, costs): carriers = ["onwind", "solar", "solar-hsat"] gens = n.generators.index[n.generators.carrier.isin(carriers)] @@ -1771,13 +1776,12 @@ def add_h2_gas_infrastructure( costs, pop_layout, h2_cavern_file, - hydrogen_transmission_candidates, cavern_types, clustered_gas_network_file, gas_input_nodes, spatial, options, - cf_transmission, + pipeline_candidates, ): """ Add hydrogen and gas infrastructure to the network. @@ -1792,8 +1796,6 @@ def add_h2_gas_infrastructure( Population layout with index of locations/nodes h2_cavern_file : str Path to CSV file containing hydrogen cavern storage potentials - hydrogen_transmission_candidates : str - Path to GeoJSON file containing hydrogen pipeline candidates cavern_types : list List of underground storage types to consider clustered_gas_network_file : str, optional @@ -1808,14 +1810,19 @@ def add_h2_gas_infrastructure( - hydrogen_fuel_cell : bool - hydrogen_turbine : bool - hydrogen_underground_storage : bool + - gas_network : bool + - H2_retrofit : bool + - H2_network : bool - methanation : bool - coal_cc : bool - SMR_cc : bool - SMR : bool - min_part_load_methanation : float - cc_fraction : float - cf_transmission : dict - Dictionary of configuration options for transmission infrastructure. + pipeline_candidates : pd.DataFrame + Candidate pipeline corridors from `read_pipeline_candidates` + logger : logging.Logger, optional + Logger for output messages. If None, no logging is performed. Returns ------- @@ -1832,8 +1839,6 @@ def add_h2_gas_infrastructure( """ # Set defaults options = options or {} - hydrogen_retrofit = cf_transmission["hydrogen"]["retrofit"] - gas_connectivity_upgrade = cf_transmission["gas"]["connectivity_upgrade"] logger.info("Add hydrogen storage") @@ -1941,10 +1946,10 @@ def add_h2_gas_infrastructure( lifetime=costs.at[tech, "lifetime"], ) - if hydrogen_retrofit["enable"]: + if options["H2_retrofit"]: gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) - if cf_transmission["gas"]["enable"]: + if options["gas_network"]: logger.info( "Add natural gas infrastructure, incl. LNG terminals, production, storage and entry-points." ) @@ -1960,7 +1965,7 @@ def add_h2_gas_infrastructure( gas_pipes = pd.read_csv(clustered_gas_network_file, index_col=0) - if hydrogen_retrofit["enable"]: + if options["H2_retrofit"]: gas_pipes["p_nom_max"] = gas_pipes.p_nom gas_pipes["p_nom_min"] = 0.0 # 0.1 EUR/MWkm/a to prefer decommissioning to address degeneracy @@ -2041,7 +2046,7 @@ def add_h2_gas_infrastructure( ) # apply k_edge_augmentation weighted by length of complement edges - k_edge = gas_connectivity_upgrade + k_edge = options["gas_network_connectivity_upgrade"] if augmentation := list( k_edge_augmentation(G, k_edge, avail=complement_edges.values) ): @@ -2068,7 +2073,7 @@ def add_h2_gas_infrastructure( lifetime=costs.at["CH4 (g) pipeline", "lifetime"], ) - if hydrogen_retrofit["enable"]: + if options["H2_retrofit"]: logger.info("Add retrofitting options of existing CH4 pipes to H2 pipes.") fr = "gas pipeline" @@ -2078,52 +2083,37 @@ def add_h2_gas_infrastructure( n.add( "Link", h2_pipes.index, - bus0=h2_pipes["bus0"] + " H2", - bus1=h2_pipes["bus1"] + " H2", + bus0=h2_pipes.bus0 + " H2", + bus1=h2_pipes.bus1 + " H2", p_min_pu=-1.0, # allow that all H2 retrofit pipelines can be used in both directions - p_nom_max=h2_pipes["p_nom"] * hydrogen_retrofit["capacity_per_ch4"], + p_nom_max=h2_pipes.p_nom * options["H2_retrofit_capacity_per_CH4"], p_nom_extendable=True, - length=h2_pipes["length"], + length=h2_pipes.length, capital_cost=costs.at["H2 (g) pipeline repurposed", "capital_cost"] - * h2_pipes["length"], - tags=h2_pipes["name"], + * h2_pipes.length, + tags=h2_pipes.name, carrier="H2 pipeline retrofitted", lifetime=costs.at["H2 (g) pipeline repurposed", "lifetime"], ) - # TODO: implement offshore costs, using 1.5-2.0x multiplier - if cf_transmission["hydrogen"]["enable"]: + if options["H2_network"]: logger.info("Add options for new hydrogen pipelines.") - h2_pipes = gpd.read_file(hydrogen_transmission_candidates).copy() - if "name" not in h2_pipes.columns: - h2_pipes["name"] = h2_pipes["bus0"] + " -> " + h2_pipes["bus1"] - - if not h2_pipes.empty and not h2_pipes.iloc[0]["bus0"].endswith(" H2"): - h2_pipes[["bus0", "bus1"]] = h2_pipes[["bus0", "bus1"]] + " H2" - - h2_pipes = h2_pipes.set_index("name") - h2_pipes.index = "H2 pipeline " + h2_pipes.index - - h2_bus_ids = n.buses.index[n.buses.carrier == "H2"] - h2_pipes = h2_pipes[ - h2_pipes["bus0"].isin(h2_bus_ids) & h2_pipes["bus1"].isin(h2_bus_ids) - ] + h2_pipes = pipeline_candidates.add_prefix("H2 pipeline ", axis=0) + h2_buses_loc = n.buses.query("carrier == 'H2'").location # noqa: F841 + h2_pipes = h2_pipes.query("bus0 in @h2_buses_loc and bus1 in @h2_buses_loc") + # TODO Add efficiency losses n.add( "Link", h2_pipes.index, - bus0=h2_pipes["bus0"].values, - bus1=h2_pipes["bus1"].values, + bus0=h2_pipes.bus0.values + " H2", + bus1=h2_pipes.bus1.values + " H2", p_min_pu=-1, p_nom_extendable=True, - length=( - h2_pipes["length"].values * cf_transmission["hydrogen"]["length_factor"] - ), + length=h2_pipes.length.values, capital_cost=costs.at["H2 (g) pipeline", "capital_cost"] - * h2_pipes["length"].values - * cf_transmission["hydrogen"]["length_factor"] - * cf_transmission["hydrogen"]["cost_factor"], + * h2_pipes.length.values, carrier="H2 pipeline", lifetime=costs.at["H2 (g) pipeline", "lifetime"], ) @@ -3783,7 +3773,6 @@ def add_biomass( options, spatial, cf_industry, - cf_transmission, pop_layout, biomass_potentials_file, biomass_transport_costs_file=None, @@ -3814,8 +3803,6 @@ def add_biomass( Object containing spatial information about different carriers (gas, biomass, etc.) cf_industry : dict Dictionary containing industrial sector configuration - cf_transmission : dict - Dictionary of configuration options for transmission infrastructure. pop_layout : pd.DataFrame DataFrame containing population layout information biomass_potentials_file : str @@ -3847,7 +3834,7 @@ def add_biomass( biomass_potentials = pd.read_csv(biomass_potentials_file, index_col=0) * nyears # need to aggregate potentials if gas not nodally resolved - if cf_transmission["gas"]["enable"]: + if options["gas_network"]: biogas_potentials_spatial = biomass_potentials["biogas"].rename( index=lambda x: x + " biogas" ) @@ -4515,7 +4502,6 @@ def add_industry( options: dict, spatial: SimpleNamespace, cf_industry: dict, - cf_transmission: dict, investment_year: int, ): """ @@ -4547,8 +4533,6 @@ def add_industry( (biomass, gas, oil, methanol, etc.) cf_industry : dict Industry-specific configuration parameters - cf_transmission : dict - Dictionary of configuration options for transmission infrastructure. investment_year : int Year for which investment costs should be considered HeatSystem : Enum @@ -4682,7 +4666,7 @@ def add_industry( gas_demand = industrial_demand.loc[nodes, "methane"] / nhours - if cf_transmission["gas"]["enable"]: + if options["gas_network"]: spatial_gas_demand = gas_demand.rename(index=lambda x: x + " gas for industry") else: spatial_gas_demand = gas_demand.sum() @@ -5058,7 +5042,7 @@ def add_industry( unit="t_co2", ) - if options["co2_spatial"] or cf_transmission["carbon_dioxide"]["enable"]: + if options["co2_spatial"] or options["co2_network"]: p_set = ( -industrial_demand.loc[nodes, "process emission"].rename( index=lambda x: x + " process emissions" @@ -6282,7 +6266,7 @@ def add_import_options( snakemake = mock_snakemake( "prepare_sector_network", opts="", - clusters="50", + clusters="10", sector_opts="", planning_horizons="2050", ) @@ -6293,7 +6277,6 @@ def add_import_options( options = snakemake.params.sector cf_industry = snakemake.params.industry - cf_transmission = snakemake.params.transmission ext_carriers = snakemake.params.electricity.get("extendable_carriers", dict()) investment_year = int(snakemake.wildcards.planning_horizons) @@ -6335,7 +6318,11 @@ def add_import_options( year = int(snakemake.params["energy_totals_year"]) heating_efficiencies = pd.read_csv(fn, index_col=[1, 0]).loc[year] - spatial = define_spatial(pop_layout.index, options, cf_transmission) + spatial = define_spatial(pop_layout.index, options) + + pipeline_candidates = read_pipeline_candidates( + snakemake.input.candidates, snakemake.params.length_factor + ) if snakemake.params.foresight in ["myopic", "perfect"]: add_lifetime_wind_solar(n, costs) @@ -6382,17 +6369,12 @@ def add_import_options( costs=costs, pop_layout=pop_layout, h2_cavern_file=snakemake.input.h2_cavern, - hydrogen_transmission_candidates=( - snakemake.input.hydrogen_transmission_candidates - if hasattr(snakemake.input, "hydrogen_transmission_candidates") - else None - ), cavern_types=snakemake.params.sector["hydrogen_underground_storage_locations"], clustered_gas_network_file=snakemake.input.clustered_gas_network, gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, - cf_transmission=cf_transmission, + pipeline_candidates=pipeline_candidates, ) # Hydrogen already implemented in add_h2_gas_infrastructure @@ -6475,7 +6457,6 @@ def add_import_options( options=options, spatial=spatial, cf_industry=cf_industry, - cf_transmission=cf_transmission, pop_layout=pop_layout, biomass_potentials_file=snakemake.input.biomass_potentials, biomass_transport_costs_file=snakemake.input.biomass_transport_costs, @@ -6498,7 +6479,6 @@ def add_import_options( options=options, spatial=spatial, cf_industry=cf_industry, - cf_transmission=cf_transmission, investment_year=investment_year, ) @@ -6541,22 +6521,19 @@ def add_import_options( if options["dac"]: add_dac(n, costs) - if not cf_transmission["electricity"]["enable"]: + if not options["electricity_transmission_grid"]: decentral(n) - if not cf_transmission["hydrogen"]["enable"]: + if not options["H2_network"]: remove_h2_network(n) - if cf_transmission["carbon_dioxide"]["enable"]: + if options["co2_network"]: add_co2_network( - n=n, - carbon_dioxide_transmission_candidates=snakemake.input.carbon_dioxide_transmission_candidates, - costs=costs, - co2_transmission_cost_factor=cf_transmission["carbon_dioxide"][ - "cost_factor" - ], - co2_transmission_length_factor=cf_transmission["carbon_dioxide"][ - "length_factor" + n, + costs, + pipeline_candidates, + co2_network_cost_factor=snakemake.config["sector"][ + "co2_network_cost_factor" ], ) @@ -6596,17 +6573,13 @@ def add_import_options( limit, ) - maxext = cf_transmission["electricity"]["lines"]["max_extension"] + maxext = snakemake.params["lines"]["max_extension"] if maxext is not None: limit_individual_line_extension(n, maxext) - if cf_transmission["electricity_distribution"]["enable"]: + if options["electricity_distribution_grid"]: insert_electricity_distribution_grid( - n, - costs, - cf_transmission, - pop_layout, - snakemake.input.solar_rooftop_potentials, + n, costs, options, pop_layout, snakemake.input.solar_rooftop_potentials ) if options["enhanced_geothermal"].get("enable", False): @@ -6624,25 +6597,15 @@ def add_import_options( if options["imports"]["enable"]: add_import_options(n, costs, options, gas_input_nodes) - if options["gas_distribution_grid_cost"]: - insert_gas_distribution_cost(n, costs, options=options) - - if options["electricity_grid_connection_cost"]: - add_electricity_grid_connection_cost(n, costs) + if options["gas_distribution_grid"]: + insert_gas_distribution_costs(n, costs, options=options) - transmission_efficiency_map = { - "DC": cf_transmission["electricity"]["links"]["efficiency"], - "H2 pipeline": cf_transmission["hydrogen"]["efficiency"], - "gas pipeline": cf_transmission["gas"]["efficiency"], - "electricity distribution grid": cf_transmission["electricity_distribution"][ - "efficiency" - ], - } + if options["electricity_grid_connection"]: + add_electricity_grid_connection(n, costs) - for carrier, efficiency_config in transmission_efficiency_map.items(): - if efficiency_config["enable"]: - losses = {k: v for k, v in efficiency_config.items() if k != "enable"} - lossy_bidirectional_links(n, carrier, losses) + for k, v in options["transmission_efficiency"].items(): + if k in options["transmission_efficiency"]["enable"]: + lossy_bidirectional_links(n, k, v) # Workaround: Remove lines with conflicting (and unrealistic) properties # cf. https://github.com/PyPSA/pypsa-eur/issues/444 diff --git a/scripts/simplify_network.py b/scripts/simplify_network.py index fc94c4c941..6ba67f0700 100644 --- a/scripts/simplify_network.py +++ b/scripts/simplify_network.py @@ -432,9 +432,7 @@ def remove_converters(n: pypsa.Network) -> pypsa.Network: Nyears = n.snapshot_weightings.objective.sum() / 8760 buses_prev, lines_prev, links_prev = len(n.buses), len(n.lines), len(n.links) - linetype_380 = snakemake.config["transmission"]["electricity"]["lines"]["types"][ - 380 - ] + linetype_380 = snakemake.config["lines"]["types"][380] n, trafo_map = simplify_network_to_380(n, linetype_380) busmaps = [trafo_map] diff --git a/scripts/solve_network.py b/scripts/solve_network.py index 59132e3fa3..4b6429b47c 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1137,9 +1137,7 @@ def add_pipe_retrofit_constraint(n): p_nom = n.model["Link-p_nom"] - CH4_per_H2 = ( - 1 / n.params["transmission"]["hydrogen"]["retrofit"]["capacity_per_ch4"] - ) + CH4_per_H2 = 1 / n.config["sector"]["H2_retrofit_capacity_per_CH4"] lhs = p_nom.loc[gas_pipes_i] + CH4_per_H2 * p_nom.loc[h2_retrofitted_i] rhs = n.links.p_nom[gas_pipes_i] if not PYPSA_V1: diff --git a/test/test_base_network.py b/test/test_base_network.py index 5131f3b41e..905c51faea 100644 --- a/test/test_base_network.py +++ b/test/test_base_network.py @@ -82,11 +82,7 @@ def test_get_linetype_by_voltage(config): line_type_list = [] for v_nom in v_nom_list: - line_type_list.append( - _get_linetype_by_voltage( - v_nom, config["transmission"]["electricity"]["lines"]["types"] - ) - ) + line_type_list.append(_get_linetype_by_voltage(v_nom, config["lines"]["types"])) assert len(line_type_list) == len(reference_list) assert all([x == y for x, y in zip(line_type_list, reference_list)]) From 85d23c595e48d46635637fb8cee6c479b1557cdf Mon Sep 17 00:00:00 2001 From: Fabian Neumann Date: Tue, 6 Oct 2026 11:27:33 +0200 Subject: [PATCH 30/32] docs: document pipeline candidate rules and topology change Add the two rules to the rules overview, update the hydrogen and carbon management design pages and add a release note. Rewrite the module docstrings to the template of the rules pages. --- doc/design/carbon-management.md | 4 +-- doc/design/hydrogen.md | 5 +-- doc/release_notes.md | 2 ++ doc/rules/carriers.md | 4 +++ scripts/build_transmission_delaunay_graph.py | 33 ++++---------------- scripts/build_transmission_topology.py | 29 +++++------------ 6 files changed, 24 insertions(+), 53 deletions(-) diff --git a/doc/design/carbon-management.md b/doc/design/carbon-management.md index 294cbe813a..471ed03cfc 100644 --- a/doc/design/carbon-management.md +++ b/doc/design/carbon-management.md @@ -95,8 +95,8 @@ larger and far from settlements, and onshore formations as well. ## Transport CO~2~ can be transported in a pipeline network between regions, onshore and -submarine, whose capacity is optimised, with candidate routes following the -existing electricity network. An optional compression stage with its +submarine, whose capacity is optimised, with the same candidate corridors as +new [hydrogen pipelines](hydrogen.md#transport). An optional compression stage with its electricity demand can be added for transport in dense phase. Alternatively CO~2~ is copperplated, with a transport and storage cost per tonne. Venting captured CO~2~ back to the atmosphere can be allowed as an escape valve. diff --git a/doc/design/hydrogen.md b/doc/design/hydrogen.md index 4f516d2d6a..e8a0f9eded 100644 --- a/doc/design/hydrogen.md +++ b/doc/design/hydrogen.md @@ -58,8 +58,9 @@ constraint is usually where caverns exist, not how much they can hold. ## Transport -Hydrogen moves through pipelines. New pipelines can be built along corridors -where an electricity or gas connection exists today. Optionally, existing gas +Hydrogen moves through pipelines. New pipelines can be built along candidate +corridors between neighbouring regions, taken from a Delaunay triangulation of +the regions and by default reduced to its Gabriel graph. Optionally, existing gas pipelines can be retrofitted [@gasforclimateEuropeanHydrogen2020], which is cheaper but tied to the gas network: for every unit of gas capacity taken out of service, a fixed share becomes available for hydrogen on the same route. diff --git a/doc/release_notes.md b/doc/release_notes.md index aa0a71de96..16cb63dd17 100644 --- a/doc/release_notes.md +++ b/doc/release_notes.md @@ -5,6 +5,8 @@ +* Candidate corridors for new hydrogen and carbon dioxide pipelines now come from a Delaunay triangulation of the regions instead of the electricity grid topology. By default, they are reduced to the Gabriel graph with at least one candidate per region, which yields shortest-path connections also between regions that the electricity grid does not link directly. The new option `sector: pipeline_topology` sets the minimum number of candidates per region (`min_degree`, 0 keeps the full Delaunay graph) and the maximum offshore length of a candidate (`max_offshore_distance`). This changes default results; existing configuration keys are unchanged ([#2153](https://github.com/PyPSA/pypsa-eur/pull/2153)). + * Renamed `sector: tes` to `sector: ttes` (tank thermal energy storage), as it now only controls water tanks. Until the next release, `sector: tes` is still accepted with a warning and sets both `sector: ttes` and `sector: district_heating: ptes: enable`, which keeps previous results. Feature: New option `sector: district_heating: ptes: enable` (default `true`) to enable or disable pit thermal energy storage (water pits). Like `sector: district_heating: ates: enable`, it is independent of `sector: ttes`. Fix: the thermal energy storage constraints on energy-to-power ratio and charger-discharger ratio are now added whenever water tanks, water pits or aquifer thermal energy storage are present; previously they were skipped for pit and aquifer storage with `sector: tes: false` ([#2330](https://github.com/PyPSA/pypsa-eur/pull/2330)) * Fix: Convert FOM for EGS from %/yr to fractional value ([#2350](https://github.com/PyPSA/pypsa-eur/pull/2350)). diff --git a/doc/rules/carriers.md b/doc/rules/carriers.md index 84980120e9..8c90b0592c 100644 --- a/doc/rules/carriers.md +++ b/doc/rules/carriers.md @@ -18,6 +18,10 @@ management](../design/carbon-management.md) in the design section. {{ rules("build_gas_network", "build_gas_input_locations", "cluster_gas_network", "build_salt_cavern_potentials") }} +## Pipeline candidates + +{{ rules("build_transmission_delaunay_graph", "build_transmission_topology") }} + ## Carbon sequestration {{ rules("build_co2_sequestration_potentials", "build_clustered_co2_sequestration_potentials") }} diff --git a/scripts/build_transmission_delaunay_graph.py b/scripts/build_transmission_delaunay_graph.py index 93b443cf51..f52e6b88f5 100644 --- a/scripts/build_transmission_delaunay_graph.py +++ b/scripts/build_transmission_delaunay_graph.py @@ -3,33 +3,12 @@ # SPDX-License-Identifier: MIT """ -Build the Delaunay triangulation graph for transmission corridor candidates. - -Description ------------ -Creates the full Delaunay edge table from clustered network bus coordinates, -enriched with Gabriel edge flags and offshore underwater fractions. - -The output GeoDataFrame is consumed by build_transmission_topology to filter -candidate edges. - -Outputs -------- -GeoJSON file in WGS84 (EPSG:4326) format: - -``delaunay_graph`` - Full Delaunay edge table with columns: - - - ``name``: Canonical undirected edge identifier ``"bus0 -> bus1"``. - - ``bus0``: Canonically ordered first bus id (lexicographic order). - - ``bus1``: Canonically ordered second bus id. - - ``source``: Integer index of the first bus. - - ``target``: Integer index of the second bus. - - ``length``: Great-circle edge length in km. - - ``gabriel_edge``: ``True`` if the edge satisfies the Gabriel empty - circle criterion. - - ``underwater_fraction``: Fraction of edge length over offshore regions. - - ``geometry``: LineString geometry in ``EPSG:4326``. +Build the Delaunay graph between clustered buses as candidate pipeline corridors. + +The bus coordinates of the clustered network are triangulated in a metric +projection (EPSG:3035). Each edge gets its great-circle length, a flag that +marks whether it belongs to the Gabriel graph, and the fraction of its length +that lies in offshore regions. """ import logging diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index f14837a9b6..5492e61de3 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -3,28 +3,13 @@ # SPDX-License-Identifier: MIT """ -Filter the Delaunay graph to transmission topology candidates. - -Description ------------ -Reads the full Delaunay edge table produced by build_transmission_delaunay_graph -and applies Gabriel graph filtering and min-degree backfilling to produce a -candidate edge set for the H2 and CO2 pipeline networks. - -Outputs -------- -GeoJSON file in WGS84 (EPSG:4326) format: - -``candidates`` - Selected candidate corridor table (subset of Delaunay edges) with columns: - - - ``name`` - - ``bus0`` - - ``bus1`` - - ``length`` - - ``gabriel_edge`` - - ``underwater_fraction`` - - ``geometry`` +Filter the Delaunay graph to the candidate corridors for new H2 and CO2 pipelines. + +Edges with an offshore length above the maximum are removed first. The +remaining Gabriel edges form the base set. Then the shortest remaining +Delaunay edges are added until each bus reaches the minimum degree or has no +more Delaunay neighbours. A minimum degree of zero keeps the full Delaunay +graph. """ import logging From f31e62c2c83dc365499477ca3140fb47b7d18d11 Mon Sep 17 00:00:00 2001 From: Fabian Neumann Date: Tue, 6 Oct 2026 12:19:44 +0200 Subject: [PATCH 31/32] Simplify candidate topology into one rule and script Test Gabriel edges with a k-d tree query of the edge midpoints instead of a dense distance matrix, extract Delaunay edges with numpy, simplify the min-degree backfill and merge the Delaunay and filter rules. Results are identical for 5 to 4125 buses. Add references for the Gabriel graph and k-d tree. --- doc/rules/carriers.md | 2 +- rules/build_sector.smk | 20 +- scripts/build_transmission_delaunay_graph.py | 290 ------------------- scripts/build_transmission_topology.py | 276 ++++++++---------- 4 files changed, 125 insertions(+), 463 deletions(-) delete mode 100644 scripts/build_transmission_delaunay_graph.py diff --git a/doc/rules/carriers.md b/doc/rules/carriers.md index 8c90b0592c..a85fb1cdd8 100644 --- a/doc/rules/carriers.md +++ b/doc/rules/carriers.md @@ -20,7 +20,7 @@ management](../design/carbon-management.md) in the design section. ## Pipeline candidates -{{ rules("build_transmission_delaunay_graph", "build_transmission_topology") }} +{{ rules("build_transmission_topology") }} ## Carbon sequestration diff --git a/rules/build_sector.smk b/rules/build_sector.smk index f382c03e50..d407acfaae 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -117,27 +117,11 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") -rule build_transmission_delaunay_graph: - """Builds the Delaunay graph between clustered buses as candidate pipeline corridors.""" +rule build_transmission_topology: + """Builds candidate corridors for new H2 and CO2 pipelines from the Delaunay graph of regions.""" input: network=resources("networks/clustered.nc"), offshore_shapes=resources("offshore_shapes.geojson"), - output: - delaunay_graph=resources("delaunay_graph.geojson"), - log: - logs("build_transmission_delaunay_graph.log"), - benchmark: - benchmarks("build_transmission_delaunay_graph") - resources: - mem_mb=4000, - script: - scripts("build_transmission_delaunay_graph.py") - - -rule build_transmission_topology: - """Filters the Delaunay graph to the candidate corridors for new H2 and CO2 pipelines.""" - input: - delaunay_graph=resources("delaunay_graph.geojson"), output: candidates=resources("transmission_candidates.geojson"), log: diff --git a/scripts/build_transmission_delaunay_graph.py b/scripts/build_transmission_delaunay_graph.py deleted file mode 100644 index f52e6b88f5..0000000000 --- a/scripts/build_transmission_delaunay_graph.py +++ /dev/null @@ -1,290 +0,0 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur -# -# SPDX-License-Identifier: MIT - -""" -Build the Delaunay graph between clustered buses as candidate pipeline corridors. - -The bus coordinates of the clustered network are triangulated in a metric -projection (EPSG:3035). Each edge gets its great-circle length, a flag that -marks whether it belongs to the Gabriel graph, and the fraction of its length -that lies in offshore regions. -""" - -import logging - -import geopandas as gpd -import numpy as np -import pandas as pd -import pypsa -from numpy.typing import NDArray -from pypsa.geo import haversine_pts -from scipy.spatial import Delaunay, QhullError -from shapely.geometry import LineString - -from scripts._helpers import configure_logging, set_scenario_config - -logger = logging.getLogger(__name__) - -GEO_CRS = "EPSG:4326" -DISTANCE_CRS = "EPSG:3035" - - -def delaunay_edges(coords_meter: NDArray[np.float64]) -> list[tuple[int, int]]: - """ - Build sorted unique undirected edges from Delaunay simplices. - - Parameters - ---------- - coords_meter : NDArray[np.float64] - Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. - - Returns - ------- - list[tuple[int, int]] - Sorted list of unique undirected edges represented as index pairs - ``(u, v)`` with ``u < v``. - - Raises - ------ - ValueError - If fewer than three points are provided. - """ - if len(coords_meter) < 3: - raise ValueError("Need at least 3 points to compute Delaunay triangulation.") - - try: - tri = Delaunay(coords_meter) - except QhullError: - # Robust fallback for nearly degenerate point sets. - tri = Delaunay(coords_meter, qhull_options="QJ") - - edges: set[tuple[int, int]] = set() - for simplex in tri.simplices: - for i, j in ((0, 1), (1, 2), (0, 2)): - edges.add(tuple(sorted((int(simplex[i]), int(simplex[j]))))) - return sorted(edges) - - -def pairwise_sq_dists(coords: NDArray[np.float64]) -> NDArray[np.float64]: - """ - Compute the squared Euclidean distance matrix. - - Parameters - ---------- - coords : NDArray[np.float64] - Node coordinates in Euclidean space with shape ``(n_nodes, n_dims)``. - - Returns - ------- - NDArray[np.float64] - Dense matrix ``D`` with shape ``(n_nodes, n_nodes)`` where - ``D[i, j] = ||coords[i] - coords[j]||^2``. - """ - norms = np.sum(coords * coords, axis=1) - sq = norms[:, None] + norms[None, :] - 2.0 * (coords @ coords.T) - return np.maximum(sq, 0.0) - - -def classify_gabriel_edges( - edges: NDArray[np.int64], - coords_meter: NDArray[np.float64], - eps: float = 1e-9, - chunk_size: int = 1024, -) -> NDArray[np.bool_]: - """ - Classify candidate edges as Gabriel or non-Gabriel. - - Parameters - ---------- - edges : NDArray[np.int64] - Candidate undirected edges as node index pairs with shape ``(n_edges, 2)``. - coords_meter : NDArray[np.float64] - Node coordinates in a metric CRS with shape ``(n_nodes, 2)``. - eps : float, default 1e-9 - Numerical tolerance used in the Gabriel emptiness test. - chunk_size : int, default 1024 - Batch size used to limit temporary memory during vectorized checks. - - Returns - ------- - NDArray[np.bool_] - Boolean mask of shape ``(n_edges,)`` where ``True`` marks Gabriel edges. - """ - sq_dists = pairwise_sq_dists(coords_meter) - edge_count = edges.shape[0] - is_gabriel = np.ones(edge_count, dtype=bool) - - for start in range(0, edge_count, chunk_size): - end = min(start + chunk_size, edge_count) - uv = edges[start:end] - u = uv[:, 0] - v = uv[:, 1] - dij2 = sq_dists[u, v] - - # A point k invalidates edge (u, v) when dik^2 + djk^2 < dij^2. - inside = (sq_dists[u, :] + sq_dists[v, :]) < (dij2[:, None] - eps) - row = np.arange(end - start) - inside[row, u] = False - inside[row, v] = False - is_gabriel[start:end] = ~inside.any(axis=1) - - return is_gabriel - - -def get_bus_coordinates( - n: pypsa.Network, -) -> tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]]: - """ - Extract bus ids and coordinates in geographic and metric CRS. - - Parameters - ---------- - n : pypsa.Network - Input network containing bus coordinate columns ``x`` and ``y``. - - Returns - ------- - tuple[pd.Index, NDArray[np.float64], NDArray[np.float64]] - Tuple ``(bus_ids, coords_geo, coords_meter)`` where ``coords_geo`` is in - ``EPSG:4326`` and ``coords_meter`` is in ``EPSG:3035``. - - Raises - ------ - ValueError - If fewer than two buses with valid coordinates are available. - """ - buses = n.buses.copy() - buses = buses.dropna(subset=["x", "y"]) - - if len(buses) < 2: - raise ValueError("Need at least 2 buses with coordinates to build corridors.") - - points_geo = gpd.points_from_xy(buses["x"], buses["y"], crs=GEO_CRS) - points_meter = gpd.GeoSeries(points_geo, crs=GEO_CRS).to_crs(DISTANCE_CRS) - - coords_geo = np.column_stack( - (np.asarray(points_geo.x), np.asarray(points_geo.y)) - ).astype(float) - coords_meter = np.column_stack( - (np.asarray(points_meter.x), np.asarray(points_meter.y)) - ).astype(float) - - return buses.index, coords_geo, coords_meter - - -def delaunay_triangulation( - bus_ids: pd.Index, - coords_geo: NDArray[np.float64], - coords_meter: NDArray[np.float64], -) -> gpd.GeoDataFrame: - """ - Build the full Delaunay edge table with geometry and metadata. - - Parameters - ---------- - bus_ids : pd.Index - Bus identifiers aligned with the coordinate arrays. - coords_geo : NDArray[np.float64] - Geographic coordinates (longitude, latitude) in ``EPSG:4326``. - coords_meter : NDArray[np.float64] - Projected coordinates in ``EPSG:3035`` used for metric calculations. - - Returns - ------- - gpd.GeoDataFrame - Delaunay edge table including integer node endpoints, normalized bus - names, canonical edge ``name``, ``length`` (haversine distance, km), - ``gabriel_edge`` flag, and LineString geometry. - """ - - edges = delaunay_edges(coords_meter) - edges_arr = np.array(edges, dtype=np.int64) - u = edges_arr[:, 0] - v = edges_arr[:, 1] - - logger.info("Compute Delaunay triangulation with %s edges.", len(edges)) - lengths = haversine_pts(coords_geo[u], coords_geo[v]) - gabriel_flags = classify_gabriel_edges(edges_arr, coords_meter) - edge_geoms = [LineString([coords_geo[i], coords_geo[j]]) for i, j in edges] - - bus0 = bus_ids.take(u).astype(str).to_numpy() - bus1 = bus_ids.take(v).astype(str).to_numpy() - - # Keep undirected edge naming compatible with topology naming in - # prepare_sector_network.create_network_topology. - swap_mask = bus0 > bus1 - bus0_norm = np.where(swap_mask, bus1, bus0) - bus1_norm = np.where(swap_mask, bus0, bus1) - connector = " -> " - edge_names = np.char.add( - np.char.add(bus0_norm, connector), - bus1_norm, - ) - - edges_gdf = gpd.GeoDataFrame( - { - "source": u, - "target": v, - "bus0": bus0_norm, - "bus1": bus1_norm, - "name": edge_names, - "length": lengths, - "gabriel_edge": gabriel_flags, - "geometry": edge_geoms, - }, - geometry="geometry", - crs=GEO_CRS, - ) - return edges_gdf - - -def add_underwater_fraction( - edges: gpd.GeoDataFrame, - offshore_shapes: gpd.GeoDataFrame, -) -> gpd.GeoDataFrame: - """ - Compute the fraction of each edge's length that lies within offshore regions. - - Parameters - ---------- - edges : gpd.GeoDataFrame - Edge table with LineString geometry in ``EPSG:4326``. - offshore_shapes : gpd.GeoDataFrame - Offshore region polygons used to determine submarine sections. - - Returns - ------- - gpd.GeoDataFrame - ``edges`` with an added ``underwater_fraction`` column in ``[0, 1]``. - """ - offshore_union = offshore_shapes.to_crs(DISTANCE_CRS).union_all() - geoms = edges.geometry.to_crs(DISTANCE_CRS) - edges["underwater_fraction"] = ( - (geoms.intersection(offshore_union).length / geoms.length).fillna(0.0).round(2) - ) - return edges - - -if __name__ == "__main__": - if "snakemake" not in globals(): - from scripts._helpers import mock_snakemake - - snakemake = mock_snakemake("build_transmission_delaunay_graph") - - configure_logging(snakemake) - set_scenario_config(snakemake) - - n = pypsa.Network(snakemake.input.network) - offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) - - bus_ids, coords_geo, coords_meter = get_bus_coordinates(n) - delaunay_graph = delaunay_triangulation( - bus_ids=bus_ids, - coords_geo=coords_geo, - coords_meter=coords_meter, - ) - - add_underwater_fraction(delaunay_graph, offshore_shapes) - - delaunay_graph.to_file(snakemake.output.delaunay_graph) diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index 5492e61de3..94b93d5578 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -3,13 +3,26 @@ # SPDX-License-Identifier: MIT """ -Filter the Delaunay graph to the candidate corridors for new H2 and CO2 pipelines. - -Edges with an offshore length above the maximum are removed first. The -remaining Gabriel edges form the base set. Then the shortest remaining -Delaunay edges are added until each bus reaches the minimum degree or has no -more Delaunay neighbours. A minimum degree of zero keeps the full Delaunay -graph. +Build the candidate corridors for new H2 and CO2 pipelines between clustered buses. + +The bus coordinates are triangulated in a metric projection (EPSG:3035). Each +edge gets its great-circle length and the fraction of its length that lies in +offshore regions; edges with an offshore length above the maximum are removed. +The remaining edges of the Gabriel graph form the base set: an edge belongs to +it if the circle with the edge as its diameter contains no other bus [Gabriel +and Sokal, 1969]. As both ends lie on this circle, the test only needs the +distance from the edge midpoint to the nearest bus, found with a k-d tree +[Bentley, 1975], and only Delaunay edges need testing, as the Gabriel graph is +a subgraph of the Delaunay triangulation [Matula and Sokal, 1980]. Then the +shortest remaining Delaunay edges are added until each bus reaches the minimum +degree or has no more Delaunay neighbours. A minimum degree of zero keeps the +full Delaunay graph. + +References +---------- +- Gabriel and Sokal (1969), [A New Statistical Approach to Geographic Variation Analysis](https://doi.org/10.2307/2412323) +- Matula and Sokal (1980), [Properties of Gabriel Graphs Relevant to Geographic Variation Research and the Clustering of Points in the Plane](https://doi.org/10.1111/j.1538-4632.1980.tb00031.x) +- Bentley (1975), [Multidimensional binary search trees used for associative searching](https://doi.org/10.1145/361002.361007) """ import logging @@ -17,161 +30,135 @@ import geopandas as gpd import numpy as np import pandas as pd +import pypsa +from pypsa.geo import haversine_pts +from scipy.spatial import Delaunay, KDTree +from shapely.geometry import LineString from scripts._helpers import configure_logging, set_scenario_config logger = logging.getLogger(__name__) -COLS_EDGES = [ - "name", - "bus0", - "bus1", - "length", - "gabriel_edge", - "geometry", - "underwater_fraction", -] +DISTANCE_CRS = "EPSG:3035" -def enforce_min_degree( - delaunay_graph: gpd.GeoDataFrame, - node_count: int, - min_degree: int, +def delaunay_edges(coords: np.ndarray) -> np.ndarray: + """Return the unique Delaunay edges as sorted index pairs ``(i, j)`` with ``i < j``.""" + triangles = Delaunay(coords).simplices + edges = np.vstack( + [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [0, 2]]] + ) + return np.unique(np.sort(edges, axis=1), axis=0) + + +def is_gabriel(edges: np.ndarray, coords: np.ndarray) -> np.ndarray: + """Flag the edges with no other point closer to their midpoint than half their length.""" + midpoints = coords[edges].mean(axis=1) + radius = np.linalg.norm(coords[edges[:, 0]] - coords[edges[:, 1]], axis=1) / 2 + distance, _ = KDTree(coords).query(midpoints) + return distance >= radius * (1 - 1e-9) + + +def build_delaunay_graph( + n: pypsa.Network, offshore_shapes: gpd.GeoDataFrame ) -> gpd.GeoDataFrame: """ - Add shortest Delaunay edges to satisfy a minimum node degree. - - Iterates over all Delaunay edges in ascending order of length and adds - edges to the selected set until every node reaches the requested minimum - degree, or until no further Delaunay edges are available for that node. + Build the Delaunay edges between buses. Parameters ---------- - delaunay_graph : gpd.GeoDataFrame - Full Delaunay triangulation edge table. - Must contain integer columns ``source``, ``target``, and ``length``, - with canonical ordering ``source < target`` and a boolean - ``gabriel_edge`` column. - node_count : int - Total number of nodes in the graph. Used to size degree arrays. - min_degree : int - Minimum number of edges required per node. Values <= 0 disable - Gabriel filtering and return the full ``delaunay_graph``. + n : pypsa.Network + Network with bus coordinates ``x`` and ``y`` in EPSG:4326. + offshore_shapes : gpd.GeoDataFrame + Offshore regions used for the offshore fraction of each edge. Returns ------- gpd.GeoDataFrame - Edge GeoDataFrame with the same schema as ``delaunay_graph``. - For ``min_degree > 0``, starts from Gabriel edges and extends with any - backfill edges needed to meet the degree constraint. - Nodes whose maximum possible Delaunay degree is below ``min_degree`` - are handled gracefully with a warning. - - Warns - ----- - Logs a warning if the minimum degree cannot be fully satisfied, either - because a node has too few Delaunay neighbours (unreachable) or because - the greedy backfill did not converge (unmet after backfill). + One row per edge with columns name, bus0, bus1 (in lexicographic order), + length (km), gabriel_edge, underwater_fraction and geometry. """ - if min_degree <= 0: - logger.info( - "Gabriel filtering disabled via min_degree=%s; returning full Delaunay graph.", - min_degree, - ) - return delaunay_graph.copy() - - result = delaunay_graph[delaunay_graph["gabriel_edge"]].copy() - all_sorted = delaunay_graph.sort_values("length").reset_index(drop=True) - source_arr = all_sorted["source"].to_numpy(dtype=int) - target_arr = all_sorted["target"].to_numpy(dtype=int) - - max_possible_degree = np.bincount( - np.concatenate([source_arr, target_arr]), minlength=node_count - ) - - sel_source = result["source"].to_numpy(dtype=int) - sel_target = result["target"].to_numpy(dtype=int) - edge_set: set[tuple[int, int]] = set(zip(sel_source, sel_target)) - degrees = np.bincount( - np.concatenate([sel_source, sel_target]), minlength=node_count + buses = n.buses.dropna(subset=["x", "y"]) + lonlat = buses[["x", "y"]].to_numpy() + points = gpd.GeoSeries(gpd.points_from_xy(*lonlat.T), crs="EPSG:4326") + coords = points.to_crs(DISTANCE_CRS).get_coordinates().to_numpy() + + edges = delaunay_edges(coords) + logger.info(f"Delaunay triangulation has {len(edges)} edges.") + + bus0, bus1 = np.sort(buses.index.to_numpy()[edges], axis=1).T + graph = gpd.GeoDataFrame( + { + "name": bus0 + " -> " + bus1, + "bus0": bus0, + "bus1": bus1, + "length": haversine_pts(lonlat[edges[:, 0]], lonlat[edges[:, 1]]), + "gabriel_edge": is_gabriel(edges, coords), + }, + geometry=[LineString(lonlat[e]) for e in edges], + crs="EPSG:4326", ) - deficits = np.maximum(0, min_degree - degrees) - remaining_deficits = int(deficits.sum()) - additions_idx: list[int] = [] - - for idx, (u, v) in enumerate(zip(source_arr, target_arr)): - if remaining_deficits == 0: - break - if (u, v) in edge_set: - continue - if deficits[u] <= 0 and deficits[v] <= 0: - continue - edge_set.add((u, v)) - additions_idx.append(idx) - if deficits[u] > 0: - deficits[u] -= 1 - remaining_deficits -= 1 - if deficits[v] > 0: - deficits[v] -= 1 - remaining_deficits -= 1 - - if additions_idx: - to_add = all_sorted.iloc[additions_idx] - result = gpd.GeoDataFrame( - pd.concat([result, to_add], ignore_index=True), - geometry="geometry", - crs=delaunay_graph.crs, - ) - - result_source = result["source"].to_numpy(dtype=int) - result_target = result["target"].to_numpy(dtype=int) - achieved_degree = np.bincount( - np.concatenate([result_source, result_target]), minlength=node_count + lines = graph.geometry.to_crs(DISTANCE_CRS) + offshore = offshore_shapes.to_crs(DISTANCE_CRS).union_all() + graph["underwater_fraction"] = ( + (lines.intersection(offshore).length / lines.length).fillna(0.0).round(2) ) - target_degree = np.minimum(min_degree, max_possible_degree) - unreachable_mask = max_possible_degree < min_degree - unmet_mask = achieved_degree < target_degree + return graph - if np.any(unreachable_mask) or np.any(unmet_mask): - logger.warning( - "Min-degree not fully met: requested=%s, unreachable=%s/%s, unmet_after_backfill=%s/%s.", - min_degree, - int(unreachable_mask.sum()), - node_count, - int(unmet_mask.sum()), - node_count, - ) - return result - - -def filter_by_max_offshore_haversine_distance( - delaunay_graph: gpd.GeoDataFrame, - max_offdist: float = float("inf"), +def enforce_min_degree( + graph: gpd.GeoDataFrame, buses: pd.Index, min_degree: int ) -> gpd.GeoDataFrame: """ - Filter Delaunay edges by maximum offshore haversine distance. + Select the Gabriel edges and add the shortest other edges up to a minimum degree. Parameters ---------- - delaunay_graph : gpd.GeoDataFrame - Full Delaunay edge table. - max_offdist : float, default inf - Maximum allowed offshore edge length in km, computed as - ``underwater_fraction * length``. ``inf`` disables this filter. + graph : gpd.GeoDataFrame + Delaunay edges with columns bus0, bus1, length and gabriel_edge. + buses : pd.Index + All buses, used to report buses below the minimum degree. + min_degree : int + Minimum number of edges per bus. Values <= 0 return all edges. Returns ------- gpd.GeoDataFrame - Delaunay edge table after applying offshore-distance filtering. + Selected edges. """ - if np.isfinite(max_offdist): - offshore_length = ( - delaunay_graph["length"] * delaunay_graph["underwater_fraction"] + if min_degree <= 0: + return graph + + selected = graph["gabriel_edge"].to_numpy(copy=True) + gabriel_ends = graph.loc[selected, ["bus0", "bus1"]].stack().value_counts() + degree = pd.Series(0, index=buses).add(gabriel_ends, fill_value=0) + + for i in np.argsort(graph["length"].to_numpy(), kind="stable"): + u, v = graph["bus0"].iat[i], graph["bus1"].iat[i] + if not selected[i] and min(degree[u], degree[v]) < min_degree: + selected[i] = True + degree[[u, v]] += 1 + + if unmet := int((degree < min_degree).sum()): + logger.warning( + f"{unmet} of {len(degree)} buses have fewer than {min_degree} candidate corridors." ) - return delaunay_graph[offshore_length <= max_offdist].copy() - return delaunay_graph.copy() + return graph[selected] + + +def build_transmission_topology( + n: pypsa.Network, + offshore_shapes: gpd.GeoDataFrame, + min_degree: int, + max_offshore_distance: float, +) -> gpd.GeoDataFrame: + """Build the candidate corridors; see the module docstring for the method.""" + graph = build_delaunay_graph(n, offshore_shapes) + offshore_length = graph["length"] * graph["underwater_fraction"] + candidates = graph[offshore_length <= max_offshore_distance] + buses = n.buses.dropna(subset=["x", "y"]).index + return enforce_min_degree(candidates, buses, min_degree) if __name__ == "__main__": @@ -183,30 +170,11 @@ def filter_by_max_offshore_haversine_distance( configure_logging(snakemake) set_scenario_config(snakemake) - min_degree = snakemake.params.pipeline_topology["min_degree"] - max_offdist = snakemake.params.pipeline_topology["max_offshore_distance"] - - delaunay_graph = gpd.read_file(snakemake.input.delaunay_graph) - - source_arr = delaunay_graph["source"].to_numpy(dtype=int) - target_arr = delaunay_graph["target"].to_numpy(dtype=int) - node_count = int(max(source_arr.max(), target_arr.max())) + 1 + n = pypsa.Network(snakemake.input.network) + offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) + params = snakemake.params.pipeline_topology - logger.info( - "Filtering topology: min_degree=%s, max_offdist=%s.", - min_degree, - max_offdist, + candidates = build_transmission_topology( + n, offshore_shapes, params["min_degree"], params["max_offshore_distance"] ) - - candidate_pool = filter_by_max_offshore_haversine_distance( - delaunay_graph=delaunay_graph, - max_offdist=max_offdist, - ) - - selected_edges = enforce_min_degree( - delaunay_graph=candidate_pool, - node_count=node_count, - min_degree=min_degree, - )[COLS_EDGES] - - selected_edges.to_file(snakemake.output.candidates) + candidates.to_file(snakemake.output.candidates) From 22e7a972a502b8560e49dfb888f6e4cdf1b94b5b Mon Sep 17 00:00:00 2001 From: Fabian Neumann Date: Tue, 3 Nov 2026 17:51:40 +0100 Subject: [PATCH 32/32] Per-carrier transport corridors, biomass transport and review fixes - sector: transport_corridors sets min_degree and max_offshore_distance separately for H2, CO2 and biomass; one rule, one file, one boolean column per carrier - biomass transport uses the Delaunay corridors in both directions - new H2 pipelines can also follow existing gas pipelines - triangulate with Qhull QJ so collinear and coincident buses keep their corridors; up to three buses are connected pairwise - consider AC buses only and list buses below the minimum degree - unit tests for degenerate layouts and carrier-specific settings - shorter release note, method described in the design docs Co-Authored-By: Claude Opus 5.5 --- config/config.default.yaml | 13 +- config/schema.default.json | 216 ++++++++++++++++++----- doc/design/biomass.md | 2 + doc/design/carbon-management.md | 6 +- doc/design/hydrogen.md | 11 +- doc/release_notes.md | 2 +- doc/rules/carriers.md | 2 +- rules/build_sector.smk | 4 +- rules/compose.smk | 6 +- scripts/build_transmission_topology.py | 101 +++++++---- scripts/lib/validation/config/sector.py | 31 +++- scripts/prepare_sector_network.py | 71 +++++--- test/test_build_transmission_topology.py | 98 ++++++++++ 13 files changed, 440 insertions(+), 123 deletions(-) create mode 100644 test/test_build_transmission_topology.py diff --git a/config/config.default.yaml b/config/config.default.yaml index 277f900350..19e4ae99f6 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -1031,9 +1031,16 @@ sector: CO2 pipeline: efficiency_per_1000km: 1 H2_network: true - pipeline_topology: - min_degree: 1 - max_offshore_distance: .inf + transport_corridors: + H2: + min_degree: 1 + max_offshore_distance: .inf + CO2: + min_degree: 1 + max_offshore_distance: .inf + biomass: + min_degree: 1 + max_offshore_distance: .inf gas_network: true H2_retrofit: false H2_retrofit_capacity_per_CH4: 0.6 diff --git a/config/schema.default.json b/config/schema.default.json index 67ee7503ee..42d05ddb29 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5719,20 +5719,59 @@ "description": "Add option for new hydrogen pipelines.", "type": "boolean" }, - "pipeline_topology": { - "description": "Configuration for `sector.pipeline_topology` settings.", + "transport_corridors": { + "description": "Configuration for `sector.transport_corridors` settings.", "properties": { - "min_degree": { - "default": 1, - "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", - "minimum": 0, - "type": "integer" + "H2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } }, - "max_offshore_distance": { - "default": null, - "description": "Maximum offshore length of a candidate pipeline (km).", - "exclusiveMinimum": 0, - "type": "number" + "CO2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "biomass": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } } } }, @@ -7401,6 +7440,23 @@ "grid_codes" ] }, + "_CorridorConfig": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, "_CutoutConfig": { "description": "Configuration for a single cutout in `atlite.cutouts`.", "properties": { @@ -8995,23 +9051,6 @@ "year" ] }, - "_PipelineTopologyConfig": { - "description": "Configuration for `sector.pipeline_topology` settings.", - "properties": { - "min_degree": { - "default": 1, - "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", - "minimum": 0, - "type": "integer" - }, - "max_offshore_distance": { - "default": null, - "description": "Maximum offshore length of a candidate pipeline (km).", - "exclusiveMinimum": 0, - "type": "number" - } - } - }, "_PostDiscretizationConfig": { "description": "Configuration for `solving.options.post_discretization` settings.", "properties": { @@ -9857,6 +9896,62 @@ } } }, + "_TransportCorridorsConfig": { + "description": "Configuration for `sector.transport_corridors` settings.", + "properties": { + "H2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "CO2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "biomass": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + } + } + }, "_UserAgentConfig": { "description": "Configuration for `overpass_api.user_agent` settings.", "properties": { @@ -13296,20 +13391,59 @@ "description": "Add option for new hydrogen pipelines.", "type": "boolean" }, - "pipeline_topology": { - "description": "Configuration for `sector.pipeline_topology` settings.", + "transport_corridors": { + "description": "Configuration for `sector.transport_corridors` settings.", "properties": { - "min_degree": { - "default": 1, - "description": "Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", - "minimum": 0, - "type": "integer" + "H2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } }, - "max_offshore_distance": { - "default": null, - "description": "Maximum offshore length of a candidate pipeline (km).", - "exclusiveMinimum": 0, - "type": "number" + "CO2": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } + }, + "biomass": { + "description": "Configuration for the candidate corridors of one carrier.", + "properties": { + "min_degree": { + "default": 1, + "description": "Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + "minimum": 0, + "type": "integer" + }, + "max_offshore_distance": { + "default": null, + "description": "Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + "exclusiveMinimum": 0, + "type": "number" + } + } } } }, diff --git a/doc/design/biomass.md b/doc/design/biomass.md index 17a81d7a24..8596b294ff 100644 --- a/doc/design/biomass.md +++ b/doc/design/biomass.md @@ -73,6 +73,8 @@ counts as negative emissions when it is sequestered, see Solid biomass can be one European pool or regionally resolved. When resolved, transport between regions can be free or priced per distance with country-specific costs, which reflects road transport of bulky feedstocks. +Priced transport follows the same candidate corridors as new +[hydrogen pipelines](hydrogen.md#transport). Biogas follows the gas network if that is resolved. ## Further reading diff --git a/doc/design/carbon-management.md b/doc/design/carbon-management.md index 471ed03cfc..f1075c6aa3 100644 --- a/doc/design/carbon-management.md +++ b/doc/design/carbon-management.md @@ -95,8 +95,10 @@ larger and far from settlements, and onshore formations as well. ## Transport CO~2~ can be transported in a pipeline network between regions, onshore and -submarine, whose capacity is optimised, with the same candidate corridors as -new [hydrogen pipelines](hydrogen.md#transport). An optional compression stage with its +submarine, whose capacity is optimised, with the same Delaunay-based candidate +corridors as new [hydrogen pipelines](hydrogen.md#transport). The submarine +share of each corridor is the share of its straight line that crosses +offshore regions. An optional compression stage with its electricity demand can be added for transport in dense phase. Alternatively CO~2~ is copperplated, with a transport and storage cost per tonne. Venting captured CO~2~ back to the atmosphere can be allowed as an escape valve. diff --git a/doc/design/hydrogen.md b/doc/design/hydrogen.md index e8a0f9eded..07916371a3 100644 --- a/doc/design/hydrogen.md +++ b/doc/design/hydrogen.md @@ -59,9 +59,14 @@ constraint is usually where caverns exist, not how much they can hold. ## Transport Hydrogen moves through pipelines. New pipelines can be built along candidate -corridors between neighbouring regions, taken from a Delaunay triangulation of -the regions and by default reduced to its Gabriel graph. Optionally, existing gas -pipelines can be retrofitted [@gasforclimateEuropeanHydrogen2020], which is +corridors between neighbouring regions and along existing gas pipelines if the +gas network is modelled. The corridors come from a Delaunay triangulation of +the regions, by default reduced to its Gabriel graph, which keeps a direct +link only if no third region lies within the circle spanned by it. Regions +left with too few corridors receive their shortest remaining links, and +corridors can be limited in their offshore length. The same method gives the +corridors for CO~2~ pipelines and biomass transport, each with its own +settings. Optionally, existing gas pipelines can be retrofitted [@gasforclimateEuropeanHydrogen2020], which is cheaper but tied to the gas network: for every unit of gas capacity taken out of service, a fixed share becomes available for hydrogen on the same route. If the gas network is not resolved regionally, the retrofit option still diff --git a/doc/release_notes.md b/doc/release_notes.md index 08084651df..4c5d16d50d 100644 --- a/doc/release_notes.md +++ b/doc/release_notes.md @@ -5,7 +5,7 @@ -* Candidate corridors for new hydrogen and carbon dioxide pipelines now come from a Delaunay triangulation of the regions instead of the electricity grid topology. By default, they are reduced to the Gabriel graph with at least one candidate per region, which yields shortest-path connections also between regions that the electricity grid does not link directly. The new option `sector: pipeline_topology` sets the minimum number of candidates per region (`min_degree`, 0 keeps the full Delaunay graph) and the maximum offshore length of a candidate (`max_offshore_distance`). This changes default results; existing configuration keys are unchanged ([#2153](https://github.com/PyPSA/pypsa-eur/pull/2153)). +* Candidate corridors for new hydrogen and carbon dioxide pipelines and for biomass transport now come from a Delaunay triangulation of the regions instead of the electricity grid, with settings per carrier under `sector: transport_corridors`. This changes default results ([#2351](https://github.com/PyPSA/pypsa-eur/pull/2351)). * OSM network: The network comparison now also compares DC links and exports `links.csv`. Fix: AC-tagged relations are no longer imported as DC links, GW ratings are converted to MW, and incomplete DC links no longer affect converter voltage and capacity ([#2387](https://github.com/PyPSA/pypsa-eur/pull/2387)). diff --git a/doc/rules/carriers.md b/doc/rules/carriers.md index a85fb1cdd8..7289f44b9a 100644 --- a/doc/rules/carriers.md +++ b/doc/rules/carriers.md @@ -18,7 +18,7 @@ management](../design/carbon-management.md) in the design section. {{ rules("build_gas_network", "build_gas_input_locations", "cluster_gas_network", "build_salt_cavern_potentials") }} -## Pipeline candidates +## Transport corridors {{ rules("build_transmission_topology") }} diff --git a/rules/build_sector.smk b/rules/build_sector.smk index e806d9fa21..fd06e0061b 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -118,7 +118,7 @@ rule cluster_gas_network: rule build_transmission_topology: - """Builds candidate corridors for new H2 and CO2 pipelines from the Delaunay graph of regions.""" + """Builds candidate corridors for H2, CO2 and biomass transport from the Delaunay graph of regions.""" input: network=resources("networks/clustered.nc"), offshore_shapes=resources("offshore_shapes.geojson"), @@ -131,7 +131,7 @@ rule build_transmission_topology: resources: mem_mb=2000, params: - pipeline_topology=config_provider("sector", "pipeline_topology"), + transport_corridors=config_provider("sector", "transport_corridors"), script: scripts("build_transmission_topology.py") diff --git a/rules/compose.smk b/rules/compose.smk index 4ddd99403d..3404bb50f0 100644 --- a/rules/compose.smk +++ b/rules/compose.smk @@ -85,11 +85,7 @@ def get_compose_inputs(w): if sector["gas_network"] or {"gas", "H2"} & set(import_carriers) else [] ), - transmission_candidates=( - rules.build_transmission_topology.output.candidates - if sector["H2_network"] or sector["co2_network"] - else [] - ), + transmission_candidates=rules.build_transmission_topology.output.candidates, pop_weighted_energy_totals=resources("pop_weighted_energy_totals.csv"), pop_weighted_heat_totals=( resources("pop_weighted_heat_totals.csv") if sector["heating"] else [] diff --git a/scripts/build_transmission_topology.py b/scripts/build_transmission_topology.py index 94b93d5578..80fb41c029 100644 --- a/scripts/build_transmission_topology.py +++ b/scripts/build_transmission_topology.py @@ -3,12 +3,13 @@ # SPDX-License-Identifier: MIT """ -Build the candidate corridors for new H2 and CO2 pipelines between clustered buses. +Build the candidate corridors for new H2 and CO2 pipelines and biomass transport between clustered buses. The bus coordinates are triangulated in a metric projection (EPSG:3035). Each edge gets its great-circle length and the fraction of its length that lies in -offshore regions; edges with an offshore length above the maximum are removed. -The remaining edges of the Gabriel graph form the base set: an edge belongs to +offshore regions. The selection below runs once per carrier with its own +settings; edges with an offshore length above the carrier's maximum are +excluded. The remaining edges of the Gabriel graph form the base set: an edge belongs to it if the circle with the edge as its diameter contains no other bus [Gabriel and Sokal, 1969]. As both ends lie on this circle, the test only needs the distance from the edge midpoint to the nearest bus, found with a k-d tree @@ -43,8 +44,16 @@ def delaunay_edges(coords: np.ndarray) -> np.ndarray: - """Return the unique Delaunay edges as sorted index pairs ``(i, j)`` with ``i < j``.""" - triangles = Delaunay(coords).simplices + """ + Return the unique Delaunay edges as sorted index pairs ``(i, j)`` with ``i < j``. + + Up to three points are connected pairwise. Otherwise, the points are + triangulated after a tiny deterministic displacement (Qhull option ``QJ``), + so that collinear and coincident points are kept as vertices. + """ + if len(coords) <= 3: + return np.column_stack(np.triu_indices(len(coords), k=1)) + triangles = Delaunay(coords, qhull_options="QJ").simplices edges = np.vstack( [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [0, 2]]] ) @@ -60,15 +69,15 @@ def is_gabriel(edges: np.ndarray, coords: np.ndarray) -> np.ndarray: def build_delaunay_graph( - n: pypsa.Network, offshore_shapes: gpd.GeoDataFrame + buses: pd.DataFrame, offshore_shapes: gpd.GeoDataFrame ) -> gpd.GeoDataFrame: """ Build the Delaunay edges between buses. Parameters ---------- - n : pypsa.Network - Network with bus coordinates ``x`` and ``y`` in EPSG:4326. + buses : pd.DataFrame + Buses with coordinates ``x`` and ``y`` in EPSG:4326. offshore_shapes : gpd.GeoDataFrame Offshore regions used for the offshore fraction of each edge. @@ -78,7 +87,6 @@ def build_delaunay_graph( One row per edge with columns name, bus0, bus1 (in lexicographic order), length (km), gabriel_edge, underwater_fraction and geometry. """ - buses = n.buses.dropna(subset=["x", "y"]) lonlat = buses[["x", "y"]].to_numpy() points = gpd.GeoSeries(gpd.points_from_xy(*lonlat.T), crs="EPSG:4326") coords = points.to_crs(DISTANCE_CRS).get_coordinates().to_numpy() @@ -107,58 +115,87 @@ def build_delaunay_graph( return graph -def enforce_min_degree( - graph: gpd.GeoDataFrame, buses: pd.Index, min_degree: int -) -> gpd.GeoDataFrame: +def select_corridors( + graph: gpd.GeoDataFrame, + buses: pd.Index, + min_degree: int, + max_offshore_distance: float, +) -> pd.Series: """ Select the Gabriel edges and add the shortest other edges up to a minimum degree. Parameters ---------- graph : gpd.GeoDataFrame - Delaunay edges with columns bus0, bus1, length and gabriel_edge. + Delaunay edges with columns bus0, bus1, length, underwater_fraction and + gabriel_edge. buses : pd.Index All buses, used to report buses below the minimum degree. min_degree : int - Minimum number of edges per bus. Values <= 0 return all edges. + Minimum number of edges per bus. Values <= 0 select all edges. + max_offshore_distance : float + Maximum offshore length of an edge (km). Returns ------- - gpd.GeoDataFrame - Selected edges. + pd.Series + Boolean mask of the selected edges. """ + allowed = graph["length"] * graph["underwater_fraction"] <= max_offshore_distance if min_degree <= 0: - return graph + return allowed - selected = graph["gabriel_edge"].to_numpy(copy=True) + selected = (graph["gabriel_edge"] & allowed).to_numpy(copy=True) gabriel_ends = graph.loc[selected, ["bus0", "bus1"]].stack().value_counts() degree = pd.Series(0, index=buses).add(gabriel_ends, fill_value=0) for i in np.argsort(graph["length"].to_numpy(), kind="stable"): u, v = graph["bus0"].iat[i], graph["bus1"].iat[i] - if not selected[i] and min(degree[u], degree[v]) < min_degree: + if ( + allowed.iat[i] + and not selected[i] + and min(degree[u], degree[v]) < min_degree + ): selected[i] = True degree[[u, v]] += 1 - if unmet := int((degree < min_degree).sum()): + if not (unmet := degree.index[degree < min_degree]).empty: logger.warning( - f"{unmet} of {len(degree)} buses have fewer than {min_degree} candidate corridors." + f"{len(unmet)} of {len(degree)} buses have fewer than {min_degree} " + f"candidate corridors: {', '.join(unmet)}" ) - return graph[selected] + return pd.Series(selected, index=graph.index) def build_transmission_topology( n: pypsa.Network, offshore_shapes: gpd.GeoDataFrame, - min_degree: int, - max_offshore_distance: float, + corridors: dict[str, dict], ) -> gpd.GeoDataFrame: - """Build the candidate corridors; see the module docstring for the method.""" - graph = build_delaunay_graph(n, offshore_shapes) - offshore_length = graph["length"] * graph["underwater_fraction"] - candidates = graph[offshore_length <= max_offshore_distance] - buses = n.buses.dropna(subset=["x", "y"]).index - return enforce_min_degree(candidates, buses, min_degree) + """ + Build the candidate corridors; see the module docstring for the method. + + Parameters + ---------- + n : pypsa.Network + Network with bus coordinates ``x`` and ``y`` in EPSG:4326. + offshore_shapes : gpd.GeoDataFrame + Offshore regions used for the offshore fraction of each edge. + corridors : dict[str, dict] + Per carrier, the ``min_degree`` and ``max_offshore_distance``. + + Returns + ------- + gpd.GeoDataFrame + Delaunay edges selected for at least one carrier, with one boolean + column per carrier. + """ + buses = n.buses.query("carrier == 'AC'") + graph = build_delaunay_graph(buses, offshore_shapes) + for carrier, settings in corridors.items(): + logger.info(f"Selecting candidate corridors for {carrier}.") + graph[carrier] = select_corridors(graph, buses.index, **settings) + return graph[graph[list(corridors)].any(axis=1)] if __name__ == "__main__": @@ -172,9 +209,7 @@ def build_transmission_topology( n = pypsa.Network(snakemake.input.network) offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) - params = snakemake.params.pipeline_topology - candidates = build_transmission_topology( - n, offshore_shapes, params["min_degree"], params["max_offshore_distance"] + n, offshore_shapes, snakemake.params.transport_corridors ) candidates.to_file(snakemake.output.candidates) diff --git a/scripts/lib/validation/config/sector.py b/scripts/lib/validation/config/sector.py index da4d58e858..56df66388c 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -370,18 +370,35 @@ class _SolidBiomassImportConfig(BaseModel): ) -class _PipelineTopologyConfig(BaseModel): - """Configuration for `sector.pipeline_topology` settings.""" +class _CorridorConfig(BaseModel): + """Configuration for the candidate corridors of one carrier.""" min_degree: int = Field( 1, ge=0, - description="Minimum number of candidate pipelines per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", + description="Minimum number of candidate corridors per region. The candidates start from the Gabriel graph and add the shortest Delaunay edges until each region reaches this degree. Set to 0 to use the full Delaunay graph.", ) max_offshore_distance: float = Field( float("inf"), gt=0, - description="Maximum offshore length of a candidate pipeline (km).", + description="Maximum offshore length of a candidate corridor (km). Defaults to unlimited (`.inf`).", + ) + + +class _TransportCorridorsConfig(BaseModel): + """Configuration for `sector.transport_corridors` settings.""" + + H2: _CorridorConfig = Field( + default_factory=_CorridorConfig, + description="Candidate corridors for new hydrogen pipelines.", + ) + CO2: _CorridorConfig = Field( + default_factory=_CorridorConfig, + description="Candidate corridors for new carbon dioxide pipelines.", + ) + biomass: _CorridorConfig = Field( + default_factory=_CorridorConfig, + description="Candidate corridors for solid biomass and municipal solid waste transport.", ) @@ -892,9 +909,9 @@ class SectorConfig(BaseModel): ) H2_network: bool = Field(True, description="Add option for new hydrogen pipelines.") - pipeline_topology: _PipelineTopologyConfig = Field( - default_factory=_PipelineTopologyConfig, - description="Candidate corridors for new hydrogen and carbon dioxide pipelines, derived from the Delaunay triangulation of the regions.", + transport_corridors: _TransportCorridorsConfig = Field( + default_factory=_TransportCorridorsConfig, + description="Candidate corridors for new hydrogen and carbon dioxide pipelines and for biomass transport, derived from the Delaunay triangulation of the regions.", ) gas_network: bool = Field( True, diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index f210e1591b..d62c9e5f8f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -277,7 +277,12 @@ def haversine(p, n): def create_network_topology( - n, prefix, carriers=["DC"], connector=" -> ", bidirectional=True + n, + prefix, + carriers=["DC"], + connector=" -> ", + bidirectional=True, + include_lines=True, ): """ Create a network topology from transmission lines and link carrier @@ -292,6 +297,8 @@ def create_network_topology( bidirectional : bool, default True True: one link for each connection False: one link for each connection and direction (back and forth) + include_lines : bool, default True + Include the AC lines in addition to the links of the selected carriers. Returns ------- @@ -302,8 +309,9 @@ def create_network_topology( lk_attrs = ["bus0", "bus1", "length", "underwater_fraction"] lk_attrs = n.links.columns.intersection(lk_attrs) + lines = n.lines[ln_attrs] if include_lines else None candidates = pd.concat( - [n.lines[ln_attrs], n.links.loc[n.links.carrier.isin(carriers), lk_attrs]] + [lines, n.links.loc[n.links.carrier.isin(carriers), lk_attrs]] ).fillna(0) # base network topology purely on location not carrier @@ -331,9 +339,11 @@ def make_index(c): return topo -def read_pipeline_candidates(fn: str, length_factor: float) -> pd.DataFrame: +def read_transport_corridors( + fn: str, length_factor: float, carriers: list[str] +) -> dict[str, pd.DataFrame]: """ - Read candidate pipeline corridors between locations. + Read the candidate transport corridors between locations per carrier. Parameters ---------- @@ -341,15 +351,19 @@ def read_pipeline_candidates(fn: str, length_factor: float) -> pd.DataFrame: Path to the candidate corridors from ``build_transmission_topology``. length_factor : float Factor applied to the great-circle lengths of the corridors. + carriers : list[str] + Carriers with a boolean selection column in the file. Returns ------- - pd.DataFrame with index ``"bus0 -> bus1"`` and columns bus0, bus1, length, - underwater_fraction + dict[str, pd.DataFrame] + Per carrier, the corridors with index ``"bus0 -> bus1"`` and columns + bus0, bus1, length, underwater_fraction. """ - candidates = gpd.read_file(fn).set_index("name") - candidates["length"] *= length_factor - return pd.DataFrame(candidates[["bus0", "bus1", "length", "underwater_fraction"]]) + corridors = gpd.read_file(fn).set_index("name") + corridors["length"] *= length_factor + columns = ["bus0", "bus1", "length", "underwater_fraction"] + return {c: pd.DataFrame(corridors.loc[corridors[c], columns]) for c in carriers} def add_carrier_buses( @@ -779,7 +793,7 @@ def add_co2_network( 'CO2 pipeline' and 'CO2 submarine pipeline' with 'capital_cost' and 'lifetime' columns pipeline_candidates : pd.DataFrame - Candidate pipeline corridors from `read_pipeline_candidates` + Candidate pipeline corridors from `read_transport_corridors` co2_network_cost_factor : float, optional Factor to scale the capital costs of the CO2 network, default 1.0 co2_liquefaction : bool, optional @@ -799,9 +813,6 @@ def add_co2_network( logger.info("Adding CO2 network.") co2_links = pipeline_candidates.add_prefix("CO2 pipeline ", axis=0) - if "underwater_fraction" not in co2_links.columns: - co2_links["underwater_fraction"] = 0.0 - cost_onshore = ( (1 - co2_links.underwater_fraction) * costs.at["CO2 pipeline", "capital_cost"] @@ -1655,7 +1666,8 @@ def add_h2_gas_infrastructure( - min_part_load_methanation : float - cc_fraction : float pipeline_candidates : pd.DataFrame - Candidate pipeline corridors from `read_pipeline_candidates` + Candidate pipeline corridors from `read_transport_corridors`, extended + by the corridors of the existing gas pipelines Returns ------- @@ -1935,7 +1947,11 @@ def add_h2_gas_infrastructure( if options["H2_network"]: logger.info("Add options for new hydrogen pipelines.") - h2_pipes = pipeline_candidates.add_prefix("H2 pipeline ", axis=0) + gas_corridors = create_network_topology( + n, "", carriers=["gas pipeline"], include_lines=False + ) + h2_pipes = pipeline_candidates.combine_first(gas_corridors) + h2_pipes = h2_pipes.add_prefix("H2 pipeline ", axis=0) h2_buses_loc = n.buses.query("carrier == 'H2'").location # noqa: F841 h2_pipes = h2_pipes.query("bus0 in @h2_buses_loc and bus1 in @h2_buses_loc") @@ -3637,6 +3653,7 @@ def add_biomass( pop_layout, biomass_potentials_file, biomass_transport_costs_file=None, + transport_corridors=None, nyears=1, ): """ @@ -3671,6 +3688,9 @@ def add_biomass( biomass_transport_costs_file : str, optional Path to CSV file containing biomass transport costs data. Required if biomass_transport or biomass_spatial options are True. + transport_corridors : pd.DataFrame, optional + Candidate corridors from `read_transport_corridors`, used in both + directions. Required if the biomass_transport option is True. nyears : float Number of years for which to scale the biomass potentials. @@ -3978,8 +3998,10 @@ def add_biomass( # add biomass transport transport_costs = pd.read_csv(biomass_transport_costs_file, index_col=0) transport_costs = transport_costs.squeeze() - biomass_transport = create_network_topology( - n, "biomass transport ", bidirectional=False + reverse = transport_corridors.rename(columns={"bus0": "bus1", "bus1": "bus0"}) + reverse.index = reverse.bus0 + " -> " + reverse.bus1 + biomass_transport = pd.concat([transport_corridors, reverse]).add_prefix( + "biomass transport ", axis=0 ) # costs @@ -6193,12 +6215,10 @@ def main( spatial = define_spatial(pop_layout.index, options) - pipeline_candidates = ( - read_pipeline_candidates( - inputs.transmission_candidates, params.lines["length_factor"] - ) - if inputs.transmission_candidates - else None + corridors = read_transport_corridors( + inputs.transmission_candidates, + params.lines["length_factor"], + options["transport_corridors"], ) if foresight in ["myopic", "perfect"]: @@ -6251,7 +6271,7 @@ def main( gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, - pipeline_candidates=pipeline_candidates, + pipeline_candidates=corridors["H2"], ) # Hydrogen already implemented in add_h2_gas_infrastructure @@ -6337,6 +6357,7 @@ def main( pop_layout=pop_layout, biomass_potentials_file=inputs.biomass_potentials, biomass_transport_costs_file=inputs.biomass_transport_costs, + transport_corridors=corridors["biomass"], nyears=nyears, ) @@ -6408,7 +6429,7 @@ def main( add_co2_network( n, costs, - pipeline_candidates, + corridors["CO2"], co2_network_cost_factor=options["co2_network_cost_factor"], co2_liquefaction=options["co2_network_liquefaction"], ) diff --git a/test/test_build_transmission_topology.py b/test/test_build_transmission_topology.py new file mode 100644 index 0000000000..58e23e97eb --- /dev/null +++ b/test/test_build_transmission_topology.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +"""Tests for the candidate transport corridors in scripts/build_transmission_topology.py.""" + +import geopandas as gpd +import pypsa +import pytest +from shapely.geometry import box + +from scripts.build_transmission_topology import build_transmission_topology + +OFFSHORE = gpd.GeoDataFrame(geometry=[box(30, 30, 31, 31)], crs="EPSG:4326") + + +def network(coords: list[tuple[float, float]]) -> pypsa.Network: + n = pypsa.Network() + for i, (x, y) in enumerate(coords): + n.add("Bus", f"b{i}", x=x, y=y, carrier="AC") + n.add("Bus", "b0 H2", x=0.0, y=50.0, carrier="H2") + return n + + +@pytest.mark.parametrize( + "coords, min_degree, expected", + [ + ([(10, 50)], 1, set()), + ([(10, 50), (11, 50)], 1, {("b0", "b1")}), + ( + [(10, 50), (11, 50), (12, 50), (13, 50)], + 1, + {("b0", "b1"), ("b1", "b2"), ("b2", "b3")}, + ), + ( + [(10, 50), (12, 50), (11, 51)], + 1, + {("b0", "b1"), ("b0", "b2"), ("b1", "b2")}, + ), + ( + [(10, 50), (11, 50), (11, 51), (10, 51)], + 1, + {("b0", "b1"), ("b1", "b2"), ("b2", "b3"), ("b0", "b3")}, + ), + ( + [(10, 50), (10, 50), (11, 50)], + 1, + {("b0", "b1"), ("b0", "b2"), ("b1", "b2")}, + ), + ([(10, 50), (12, 50), (11, 50.1)], 1, {("b0", "b2"), ("b1", "b2")}), + ( + [(10, 50), (12, 50), (11, 50.1)], + 2, + {("b0", "b1"), ("b0", "b2"), ("b1", "b2")}, + ), + ( + [(10, 50), (12, 50), (11, 50.1)], + 0, + {("b0", "b1"), ("b0", "b2"), ("b1", "b2")}, + ), + ], + ids=[ + "point", + "pair", + "collinear", + "triangle", + "square", + "coincident", + "obtuse-gabriel", + "obtuse-min-degree", + "obtuse-delaunay", + ], +) +def test_candidate_corridors(coords, min_degree, expected): + """Small and degenerate layouts give the expected corridors between AC buses.""" + settings = {"min_degree": min_degree, "max_offshore_distance": float("inf")} + candidates = build_transmission_topology( + network(coords), OFFSHORE, {"H2": settings} + ) + assert set(zip(candidates.bus0, candidates.bus1)) == expected + + +def test_carrier_specific_corridors(): + """Each carrier gets its own selection; the union of all is returned.""" + inf = float("inf") + corridors = { + "H2": {"min_degree": 1, "max_offshore_distance": inf}, + "CO2": {"min_degree": 0, "max_offshore_distance": inf}, + } + candidates = build_transmission_topology( + network([(10, 50), (12, 50), (11, 50.1)]), OFFSHORE, corridors + ).set_index(["bus0", "bus1"]) + assert candidates["H2"].to_dict() == { + ("b0", "b1"): False, + ("b0", "b2"): True, + ("b1", "b2"): True, + } + assert candidates["CO2"].all()