diff --git a/config/config.default.yaml b/config/config.default.yaml index 0e336b4e0f..490b5e2c75 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -1031,6 +1031,16 @@ sector: CO2 pipeline: efficiency_per_1000km: 1 H2_network: true + 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 9e7e326e02..9c1e036f67 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5725,6 +5725,62 @@ "description": "Add option for new hydrogen pipelines.", "type": "boolean" }, + "transport_corridors": { + "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" + } + } + } + } + }, "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.", @@ -7346,6 +7402,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": { @@ -9750,6 +9823,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": { @@ -13189,6 +13318,62 @@ "description": "Add option for new hydrogen pipelines.", "type": "boolean" }, + "transport_corridors": { + "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" + } + } + } + } + }, "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/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 294cbe813a..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 candidate routes following the -existing electricity network. 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 4f516d2d6a..07916371a3 100644 --- a/doc/design/hydrogen.md +++ b/doc/design/hydrogen.md @@ -58,9 +58,15 @@ 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 -pipelines can be retrofitted [@gasforclimateEuropeanHydrogen2020], which is +Hydrogen moves through pipelines. New pipelines can be built along candidate +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 95f44d73d5..64d320dd20 100644 --- a/doc/release_notes.md +++ b/doc/release_notes.md @@ -5,6 +5,7 @@ +* 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)). * **Breaking:** Removed the integration of the discontinued OETC service. A non-empty `solving: oetc:` section in the config now raises an error ([#2398](https://github.com/PyPSA/pypsa-eur/pull/2398)). * Fix: Runs with only landlocked countries and offshore technologies disabled work again, which broke in [#2169](https://github.com/PyPSA/pypsa-eur/pull/2169). Renewable capacity estimation now skips carriers missing from `electricity: renewable_carriers` ([#2378](https://github.com/PyPSA/pypsa-eur/pull/2378)). diff --git a/doc/rules/carriers.md b/doc/rules/carriers.md index 84980120e9..7289f44b9a 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") }} +## Transport corridors + +{{ rules("build_transmission_topology") }} + ## Carbon sequestration {{ rules("build_co2_sequestration_potentials", "build_clustered_co2_sequestration_potentials") }} diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 9f3079cd5c..fd06e0061b 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -117,6 +117,25 @@ rule cluster_gas_network: scripts("cluster_gas_network.py") +rule build_transmission_topology: + """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"), + output: + candidates=resources("transmission_candidates.geojson"), + log: + logs("build_transmission_topology.log"), + benchmark: + benchmarks("build_transmission_topology") + resources: + mem_mb=2000, + params: + transport_corridors=config_provider("sector", "transport_corridors"), + script: + scripts("build_transmission_topology.py") + + rule build_daily_heat_demand: """Builds daily heat demand time series per region from cutout temperatures and population.""" input: diff --git a/rules/compose.smk b/rules/compose.smk index 23d13e6293..3404bb50f0 100644 --- a/rules/compose.smk +++ b/rules/compose.smk @@ -85,6 +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, 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 new file mode 100644 index 0000000000..80fb41c029 --- /dev/null +++ b/scripts/build_transmission_topology.py @@ -0,0 +1,215 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT + +""" +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. 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 +[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 + +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__) + +DISTANCE_CRS = "EPSG:3035" + + +def delaunay_edges(coords: np.ndarray) -> np.ndarray: + """ + 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]]] + ) + 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( + buses: pd.DataFrame, offshore_shapes: gpd.GeoDataFrame +) -> gpd.GeoDataFrame: + """ + Build the Delaunay edges between buses. + + Parameters + ---------- + 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. + + Returns + ------- + gpd.GeoDataFrame + One row per edge with columns name, bus0, bus1 (in lexicographic order), + length (km), gabriel_edge, underwater_fraction and geometry. + """ + 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", + ) + + 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) + ) + return graph + + +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, 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 select all edges. + max_offshore_distance : float + Maximum offshore length of an edge (km). + + Returns + ------- + pd.Series + Boolean mask of the selected edges. + """ + allowed = graph["length"] * graph["underwater_fraction"] <= max_offshore_distance + if min_degree <= 0: + return allowed + + 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 ( + allowed.iat[i] + and not selected[i] + and min(degree[u], degree[v]) < min_degree + ): + selected[i] = True + degree[[u, v]] += 1 + + if not (unmet := degree.index[degree < min_degree]).empty: + logger.warning( + f"{len(unmet)} of {len(degree)} buses have fewer than {min_degree} " + f"candidate corridors: {', '.join(unmet)}" + ) + return pd.Series(selected, index=graph.index) + + +def build_transmission_topology( + n: pypsa.Network, + offshore_shapes: gpd.GeoDataFrame, + corridors: dict[str, dict], +) -> gpd.GeoDataFrame: + """ + 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__": + if "snakemake" not in globals(): + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake("build_transmission_topology") + + configure_logging(snakemake) + set_scenario_config(snakemake) + + n = pypsa.Network(snakemake.input.network) + offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes) + candidates = build_transmission_topology( + 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 8ad9e5965a..56df66388c 100644 --- a/scripts/lib/validation/config/sector.py +++ b/scripts/lib/validation/config/sector.py @@ -370,6 +370,38 @@ class _SolidBiomassImportConfig(BaseModel): ) +class _CorridorConfig(BaseModel): + """Configuration for the candidate corridors of one carrier.""" + + min_degree: int = Field( + 1, + ge=0, + 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 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.", + ) + + class _ImportsConfig(BaseModel): """Configuration for `sector.imports` settings.""" @@ -877,6 +909,10 @@ class SectorConfig(BaseModel): ) H2_network: bool = Field(True, description="Add option for new hydrogen pipelines.") + 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, 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 41964ff937..d62c9e5f8f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -23,6 +23,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 @@ -276,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 @@ -291,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 ------- @@ -301,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 @@ -330,6 +339,33 @@ def make_index(c): return topo +def read_transport_corridors( + fn: str, length_factor: float, carriers: list[str] +) -> dict[str, pd.DataFrame]: + """ + Read the candidate transport corridors between locations per carrier. + + 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. + carriers : list[str] + Carriers with a boolean selection column in the file. + + Returns + ------- + dict[str, pd.DataFrame] + Per carrier, the corridors with index ``"bus0 -> bus1"`` and columns + 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( n: pypsa.Network, carrier: str, @@ -738,7 +774,9 @@ def add_co2_tracking( ) -def add_co2_network(n, costs, co2_network_cost_factor=1.0, co2_liquefaction=False): +def add_co2_network( + n, costs, pipeline_candidates, co2_network_cost_factor=1.0, co2_liquefaction=False +): """ Add CO2 transport network to the PyPSA network. @@ -754,6 +792,8 @@ def add_co2_network(n, costs, co2_network_cost_factor=1.0, co2_liquefaction=Fals Cost assumptions for different technologies. Must contain entries for 'CO2 pipeline' and 'CO2 submarine pipeline' with 'capital_cost' and 'lifetime' columns + pipeline_candidates : pd.DataFrame + 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 @@ -768,13 +808,10 @@ def add_co2_network(n, costs, co2_network_cost_factor=1.0, co2_liquefaction=Fals ----- 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 = create_network_topology(n, "CO2 pipeline ") - - if "underwater_fraction" not in co2_links.columns: - co2_links["underwater_fraction"] = 0.0 + co2_links = pipeline_candidates.add_prefix("CO2 pipeline ", axis=0) cost_onshore = ( (1 - co2_links.underwater_fraction) @@ -1589,6 +1626,7 @@ def add_h2_gas_infrastructure( gas_input_nodes, spatial, options, + pipeline_candidates, ): """ Add hydrogen and gas infrastructure to the network. @@ -1627,6 +1665,9 @@ def add_h2_gas_infrastructure( - SMR : bool - min_part_load_methanation : float - cc_fraction : float + pipeline_candidates : pd.DataFrame + Candidate pipeline corridors from `read_transport_corridors`, extended + by the corridors of the existing gas pipelines Returns ------- @@ -1906,9 +1947,11 @@ def add_h2_gas_infrastructure( if options["H2_network"]: logger.info("Add options for new hydrogen pipelines.") - h2_pipes = create_network_topology( - n, "H2 pipeline ", carriers=["DC", "gas pipeline"] + 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") @@ -3610,6 +3653,7 @@ def add_biomass( pop_layout, biomass_potentials_file, biomass_transport_costs_file=None, + transport_corridors=None, nyears=1, ): """ @@ -3644,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. @@ -3951,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 @@ -6166,6 +6215,12 @@ def main( spatial = define_spatial(pop_layout.index, options) + corridors = read_transport_corridors( + inputs.transmission_candidates, + params.lines["length_factor"], + options["transport_corridors"], + ) + if foresight in ["myopic", "perfect"]: fuel_carriers = params.fuel_carriers for carrier in fuel_carriers: @@ -6216,6 +6271,7 @@ def main( gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, + pipeline_candidates=corridors["H2"], ) # Hydrogen already implemented in add_h2_gas_infrastructure @@ -6301,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, ) @@ -6372,6 +6429,7 @@ def main( add_co2_network( n, costs, + 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()