diff --git a/config/config.default.yaml b/config/config.default.yaml index cd940e1a55..eee27b2fb3 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -1110,6 +1110,7 @@ clustering: remove_stubs_across_borders: false cluster_network: algorithm: kmeans + allow_ac_dc_mixing_in_bus_clusters: false hac_features: - wnd100m - influx_direct diff --git a/config/schema.default.json b/config/schema.default.json index 91d4502533..a6f0a0425f 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -565,6 +565,11 @@ ], "type": "string" }, + "allow_ac_dc_mixing_in_bus_clusters": { + "default": false, + "description": "Controls whether clustering is allowed to mix AC and DC buses within a bus cluster. If true, mixed clusters are coerced to AC before aggregation. If false, mixed clusters are kept separate.", + "type": "boolean" + }, "hac_features": { "description": "List of meteorological variables contained in the weather data cutout that should be considered for hierarchical clustering.", "items": { @@ -5961,6 +5966,11 @@ ], "type": "string" }, + "allow_ac_dc_mixing_in_bus_clusters": { + "default": false, + "description": "Controls whether clustering is allowed to mix AC and DC buses within a bus cluster. If true, mixed clusters are coerced to AC before aggregation. If false, mixed clusters are kept separate.", + "type": "boolean" + }, "hac_features": { "description": "List of meteorological variables contained in the weather data cutout that should be considered for hierarchical clustering.", "items": { @@ -12033,6 +12043,11 @@ ], "type": "string" }, + "allow_ac_dc_mixing_in_bus_clusters": { + "default": false, + "description": "Controls whether clustering is allowed to mix AC and DC buses within a bus cluster. If true, mixed clusters are coerced to AC before aggregation. If false, mixed clusters are kept separate.", + "type": "boolean" + }, "hac_features": { "description": "List of meteorological variables contained in the weather data cutout that should be considered for hierarchical clustering.", "items": { diff --git a/config/test/config.clusters.yaml b/config/test/config.clusters.yaml index efc9cab92a..79e9c3dc6d 100644 --- a/config/test/config.clusters.yaml +++ b/config/test/config.clusters.yaml @@ -29,7 +29,7 @@ clustering: temporal: resolution_elec: 24h simplify_network: - remove_stubs: true + remove_stubs: false remove_stubs_across_borders: false solving: diff --git a/doc/release_notes.rst b/doc/release_notes.rst index d2238ba5b2..dfeb75655d 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,6 +8,8 @@ Release Notes .. Upcoming Release .. ================= +* Fix: Prevent over-aggressive HVDC simplification in simplify_network for branched/multi-terminal DC topologies (e.g. UK/Shetland edge cases). Supernode detection now only collapses true chain nodes (degree 2) and preserves DC junctions (degree 3+) so branches are not dropped(https://github.com/PyPSA/pypsa-eur/pull/2147). + * Fix: Activate losses for `H2 pipeline retrofitted` links by default, to ensure consistency with `H2 pipeline` links. * Fix: Re-introduce capital costs for non-bicharging discharge links in ``add_electricity.py``, e.g. fuel cells. diff --git a/scripts/cluster_network.py b/scripts/cluster_network.py index 9eb9bd2fdf..c7df63678d 100644 --- a/scripts/cluster_network.py +++ b/scripts/cluster_network.py @@ -415,11 +415,6 @@ def clustering_for_n_clusters( bus_strategies.setdefault("substation_lv", lambda x: bool(x.sum())) bus_strategies.setdefault("substation_off", lambda x: bool(x.sum())) - # TODO Quick Fix for osm-prebuilt-version 0.6 - for way_i in ["way/140248154", "way/975637991"]: - if way_i in n.buses.index: - n.buses.loc[way_i, "carrier"] = "AC" - clustering = get_clustering_from_busmap( n, busmap, @@ -431,6 +426,54 @@ def clustering_for_n_clusters( return clustering +def apply_carrier_mixing_policy( + n: pypsa.Network, busmap: pd.Series, allow_ac_dc_mixing_in_bus_clusters: bool +) -> pd.Series: + """ + Handle AC/DC buses before clustering. + + If ``allow_ac_dc_mixing_in_bus_clusters`` is True, mixed AC/DC clusters are + kept as-is. If it is False, buses in mixed clusters are split by appending + the carrier directly to the cluster label, for example ``clusterAC`` and + ``clusterDC``. + + Parameters + ---------- + n : pypsa.Network + Network providing bus carrier information. + busmap : pandas.Series + Mapping from bus name to cluster label. + allow_ac_dc_mixing_in_bus_clusters : bool + Whether mixed AC/DC clusters are allowed. + + Returns + ------- + pandas.Series + Busmap, possibly with carrier suffixes added. + """ + busmap = busmap.astype(str) + carrier_by_bus = n.buses.carrier.reindex(busmap.index).astype(str) + + mixed_clusters = carrier_by_bus.groupby(busmap).nunique().loc[lambda s: s > 1].index + + if allow_ac_dc_mixing_in_bus_clusters: + if len(mixed_clusters): + logger.warning( + "`allow_ac_dc_mixing_in_bus_clusters` is enabled. Coercing bus carrier to AC in %s mixed clusters.", + len(mixed_clusters), + ) + mixed_bus_i = busmap.index[busmap.isin(mixed_clusters)] + n.buses.loc[mixed_bus_i, "carrier"] = "AC" + return busmap + + if len(mixed_clusters): + logger.info( + "Splitting %s mixed AC/DC clusters by carrier before aggregation.", + len(mixed_clusters), + ) + return busmap.str.cat(carrier_by_bus, sep="") + + def cluster_regions( busmaps: tuple | list, regions: gpd.GeoDataFrame, with_country: bool = False ) -> gpd.GeoDataFrame: @@ -516,7 +559,7 @@ def busmap_for_admin_regions( buses_subset.to_crs(epsg=3857), admin_regions.loc[admin_regions["country"] == country].to_crs(epsg=3857), how="left", - )["admin"] + )["admin"].astype(str) return buses["busmap"] @@ -586,10 +629,25 @@ def update_bus_coordinates( admin_regions["y"] = admin_regions["poi"].y busmap_df = pd.DataFrame(busmap) + + # Determine admin for each bus via spatial join of bus coordinates + # to the administrative polygons + buses_gdf = gpd.GeoDataFrame( + n.buses[["x", "y"]].copy(), + geometry=gpd.points_from_xy(n.buses["x"], n.buses["y"]), + crs=geo_crs, + ) + + # Find nearest admin region for each bus + admin_geo = admin_regions.copy() + admin_geo["admin_id"] = admin_geo.index + joined = gpd.sjoin_nearest(buses_gdf, admin_geo, how="left") + busmap_df["admin"] = joined["admin_id"].astype(str).reindex(busmap_df.index) + busmap_df = pd.merge( busmap_df, admin_regions[["x", "y"]], - left_on="busmap", + left_on="admin", right_index=True, how="left", ) @@ -603,7 +661,7 @@ def update_bus_coordinates( if "snakemake" not in globals(): from scripts._helpers import mock_snakemake - snakemake = mock_snakemake("cluster_network", clusters=60) + snakemake = mock_snakemake("cluster_network", clusters=50) configure_logging(snakemake) set_scenario_config(snakemake) @@ -686,6 +744,14 @@ def update_bus_coordinates( features=features, ) + allow_ac_dc_mixing_in_bus_clusters = params.cluster_network[ + "allow_ac_dc_mixing_in_bus_clusters" + ] + + busmap = apply_carrier_mixing_policy( + n, busmap, allow_ac_dc_mixing_in_bus_clusters + ) + clustering = clustering_for_n_clusters( n, busmap, diff --git a/scripts/lib/validation/config/clustering.py b/scripts/lib/validation/config/clustering.py index ccc412631e..1b24b8d6d6 100644 --- a/scripts/lib/validation/config/clustering.py +++ b/scripts/lib/validation/config/clustering.py @@ -65,6 +65,10 @@ class _ClusterNetworkConfig(BaseModel): "kmeans", description="Clustering algorithm to use.", ) + allow_ac_dc_mixing_in_bus_clusters: bool = Field( + False, + description="Controls whether clustering is allowed to mix AC and DC buses within a bus cluster. If true, mixed clusters are coerced to AC before aggregation. If false, mixed clusters are kept separate.", + ) hac_features: list[str] = Field( default_factory=lambda: ["wnd100m", "influx_direct"], description="List of meteorological variables contained in the weather data cutout that should be considered for hierarchical clustering.", diff --git a/scripts/simplify_network.py b/scripts/simplify_network.py index 6ba67f0700..3418bf5e44 100644 --- a/scripts/simplify_network.py +++ b/scripts/simplify_network.py @@ -135,13 +135,14 @@ def split_links(nodes, added_supernodes): seen = set() - # Supernodes are endpoints of links, identified by having lass then two neighbours or being an AC Bus - # An example for the latter is if two different links are connected to the same AC bus. + # Supernodes are buses that are not simple chain nodes within the component. + # A chain node has degree 2 inside the component; endpoints (degree 1), + # junctions (degree >=3), and AC buses are kept as supernodes. supernodes = { m for m in nodes if ( - (len(G.adj[m]) < 2 or (set(G.adj[m]) - nodes)) + (len(set(G.adj[m]) & nodes) != 2) or (n.buses.loc[m, "carrier"] == "AC") or (m in added_supernodes) ) @@ -241,9 +242,6 @@ def split_links(nodes, added_supernodes): _remove_clustered_buses_and_branches(n, busmap) - # Change carrier type of all added super_nodes to "AC" - n.buses.loc[added_supernodes, "carrier"] = "AC" - return n, busmap