From 0b6f808d7a3e65e1d112f23116249da4b054e573 Mon Sep 17 00:00:00 2001 From: nick-gorman Date: Thu, 4 Jun 2026 09:51:17 +1000 Subject: [PATCH 1/3] Tag flow-path limit timeslices with their region's demand condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-constraints templater emits region-tagged timeslices (qld_peak_demand etc.) lifted from PLEXOS, but the IASR-derived flow-path limits carried region-agnostic ones (peak_demand). For intraregional links that's harmless, but for interregional links the two tables couldn't be lined up — it wasn't clear which region's demand condition a cross-region limit belonged to. The PLEXOS model resolves it consistently: a path's forward limit is tagged with the destination region's demand condition and its reverse limit with the origin region's, because the receiving region's load is what tightens the limit. This applies that rule while the sub-regional limits are built (before any granularity aggregation, so a REZ stays symmetric even when single_region retargets its geo_to to NEM), prefixing each timeslice with the lowercased region id so the flow-path and custom-constraint tables share one vocabulary. See Open-ISP/ISPyPSA#109. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ispypsa/templater/transmission.py | 282 ++++++++++++------ .../test_create_ispypsa_inputs_template.py | 12 +- tests/test_templater/test_transmission.py | 203 +++++++------ .../test_transmission_nem_regions.py | 54 ++-- .../test_transmission_single_region.py | 30 +- 5 files changed, 367 insertions(+), 214 deletions(-) diff --git a/src/ispypsa/templater/transmission.py b/src/ispypsa/templater/transmission.py index 3f7edce2..99e44e4c 100644 --- a/src/ispypsa/templater/transmission.py +++ b/src/ispypsa/templater/transmission.py @@ -36,9 +36,11 @@ def _template_network_transmission( ) -> tuple[pd.DataFrame, pd.DataFrame]: """Creates the network_transmission_paths and network_transmission_path_limits tables. - Sub-regional paths and limits are built first; then if a coarser granularity - is requested, the result is aggregated to that level. Finally, augmentation - keys without an existing path (new parallel corridors) are appended. + Sub-regional paths and limits are built first, then each limit's timeslice is + prefixed with the NEM region whose demand condition sets it (see + ``_add_region_to_timeslices`` and Open-ISP/ISPyPSA#109). If a coarser + granularity is requested, the result is aggregated to that level. Finally, + augmentation keys without an existing path (new parallel corridors) are appended. Args: flow_path_transfer_capability: IASR flow path transfer capability table. @@ -46,8 +48,9 @@ def _template_network_transmission( for REZ transmission network limits. renewable_energy_zones: IASR renewable energy zones table. sub_regional_geography: the sub_regional ``network_geography`` table — - its geo_id -> region_id mapping is used to identify cross-region - flow paths when aggregating to a coarser granularity. + its geo_id -> region_id mapping (covering sub-regions and REZs) drives + both the timeslice region prefix and the cross-region flow-path + identification used when aggregating to a coarser granularity. regional_granularity: one of "sub_regions", "nem_regions", or "single_region". flow_path_options: granularity-filtered dict of flow-path augmentation @@ -61,7 +64,10 @@ def _template_network_transmission( I/O Example: Real IASR column names are in ``_FLOW_PATH_COLUMN_RENAMES`` and - ``_REZ_COLUMN_RENAMES``; abbreviated names are used here. + ``_REZ_COLUMN_RENAMES``; abbreviated names are used here. Timeslices are + region-prefixed: the forward limit carries the destination region's demand + condition, the reverse limit the origin region's (see + ``_add_region_to_timeslices``). Inputs: @@ -90,13 +96,16 @@ def _template_network_transmission( Q1-NQ Q1 NQ AC N1-CNSW N1 CNSW AC - returns limits: - path_id direction timeslice capacity - CQ-NQ forward peak_demand 1200 # flow path with values: 6 rows - NNSW-SQ forward peak_demand 950 # flow path with values: 6 rows - Q1-NQ forward peak_demand 750 # REZ with values: 6 rows, symmetric - MN-SA (NaN) (NaN) (NaN) # all-blank flow path -> collapsed - N1-CNSW (NaN) (NaN) (NaN) # REZ absent from limits -> collapsed + returns limits (one forward + one reverse row shown per path; 6 rows each): + path_id direction timeslice capacity + CQ-NQ forward qld_peak_demand 1200 # into NQ (QLD) + CQ-NQ reverse qld_peak_demand 1440 # into CQ (QLD): intra-region + NNSW-SQ forward qld_peak_demand 950 # into SQ (QLD) + NNSW-SQ reverse nsw_peak_demand 1450 # into NNSW (NSW) + Q1-NQ forward qld_peak_demand 750 # REZ in QLD, symmetric + Q1-NQ reverse qld_peak_demand 750 + MN-SA (NaN) (NaN) (NaN) # all-blank flow path -> collapsed + N1-CNSW (NaN) (NaN) (NaN) # REZ absent from limits -> collapsed regional_granularity = "nem_regions": @@ -107,11 +116,12 @@ def _template_network_transmission( N1-NSW N1 NSW AC # REZ geo_to retargeted # CQ-NQ dropped (intra-QLD) - returns limits: - path_id direction timeslice capacity - NSW-QLD forward peak_demand 950 - Q1-QLD forward peak_demand 750 - N1-NSW (NaN) (NaN) (NaN) # collapsed row preserved + returns limits (prefixes ride through the path-id re-keying unchanged): + path_id direction timeslice capacity + NSW-QLD forward qld_peak_demand 950 + NSW-QLD reverse nsw_peak_demand 1450 + Q1-QLD forward qld_peak_demand 750 + N1-NSW (NaN) (NaN) (NaN) # collapsed row preserved regional_granularity = "single_region": @@ -121,10 +131,10 @@ def _template_network_transmission( N1-NEM N1 NEM AC # All inter-subregional flow paths dropped; only REZ paths remain. - returns limits: - path_id direction timeslice capacity - Q1-NEM forward peak_demand 750 - N1-NEM (NaN) (NaN) (NaN) + returns limits (REZ keeps its real region prefix despite geo_to -> NEM): + path_id direction timeslice capacity + Q1-NEM forward qld_peak_demand 750 + N1-NEM (NaN) (NaN) (NaN) When ``flow_path_options`` contains keys without a matching path_id (e.g. ``CNSW-SNW`` when only ``CNSW-SNW_NTH``/``_STH`` exist), those corridors are @@ -142,14 +152,18 @@ def _template_network_transmission( paths = pd.concat([flow_paths, rez_paths], ignore_index=True) limits = pd.concat([flow_limits, rez_limits], ignore_index=True) limits = _collapse_paths_with_no_limits(limits) + region_lookup = _build_geo_region_lookup(sub_regional_geography) + limits = _add_region_to_timeslices(limits, paths, region_lookup) paths, limits = _aggregate_to_granularity( paths, limits, regional_granularity, renewable_energy_zones, - sub_regional_geography, + region_lookup, + ) + return _append_new_parallel_paths( + paths, limits, flow_path_options or {}, region_lookup ) - return _append_new_parallel_paths(paths, limits, flow_path_options or {}) def _aggregate_to_granularity( @@ -157,7 +171,7 @@ def _aggregate_to_granularity( limits: pd.DataFrame, regional_granularity: str, renewable_energy_zones: pd.DataFrame, - sub_regional_geography: pd.DataFrame, + region_lookup: dict[str, str], ) -> tuple[pd.DataFrame, pd.DataFrame]: """Dispatches to the appropriate aggregation step for the chosen granularity.""" if regional_granularity == "sub_regions": @@ -166,13 +180,100 @@ def _aggregate_to_granularity( if regional_granularity == "single_region": return _aggregate_to_single_region(paths, limits, rez_ids) if regional_granularity == "nem_regions": - region_lookup = dict( - zip(sub_regional_geography["geo_id"], sub_regional_geography["region_id"]) - ) return _aggregate_to_nem_regions(paths, limits, region_lookup, rez_ids) raise ValueError(f"Unknown regional_granularity: {regional_granularity!r}") +# --- Region-prefixed timeslices --- + + +def _build_geo_region_lookup(sub_regional_geography: pd.DataFrame) -> dict[str, str]: + """Maps every geo (sub-region, REZ, or NEM region) to its NEM region id. + + ``sub_regional_geography`` already lists both sub-regions and REZs against their + ``region_id``. NEM regions are added as identities so that geos which are + already regions — after granularity aggregation, or on new parallel corridors — + resolve to themselves. + + I/O Example: + sub_regional_geography: + geo_id geo_type region_id + NQ subregion QLD + CNSW subregion NSW + Q1 rez QLD + + returns: + {"NQ": "QLD", "CNSW": "NSW", "Q1": "QLD", "QLD": "QLD", "NSW": "NSW"} + """ + lookup = dict( + zip(sub_regional_geography["geo_id"], sub_regional_geography["region_id"]) + ) + for region in set(sub_regional_geography["region_id"]): + lookup[region] = region + return lookup + + +def _add_region_to_timeslices( + limits: pd.DataFrame, + paths: pd.DataFrame, + region_lookup: dict[str, str], +) -> pd.DataFrame: + """Prefixes each timeslice with the NEM region whose demand condition sets it. + + PLEXOS tags a path's forward (geo_from -> geo_to) limit with the *destination* + region's demand condition and its reverse limit with the *origin* region's — the + receiving region's load is what tightens the limit (Open-ISP/ISPyPSA#109). The + region id is lowercased and prefixed onto the canonical timeslice so the value + matches the custom-constraints vocabulary (``qld_peak_demand`` etc.). Collapsed + rows (NaN timeslice) are left untouched. + + Applied at the sub-regional level, before any granularity aggregation, so that a + REZ — whose forward and reverse endpoints sit in the same region — stays + symmetric even when single_region later retargets its geo_to to ``NEM``. + + I/O Example: + limits: + path_id direction timeslice capacity + NNSW-SQ forward peak_demand 950 # into SQ (QLD) + NNSW-SQ reverse peak_demand 1450 # into NNSW (NSW) + Q1-NQ reverse summer_typical 750 # REZ Q1 in QLD + SA-VIC (NaN) (NaN) (NaN) # collapsed: untouched + + paths: + path_id geo_from geo_to + NNSW-SQ NNSW SQ + Q1-NQ Q1 NQ + SA-VIC SA VIC + + region_lookup: {"NNSW": "NSW", "SQ": "QLD", "Q1": "QLD", "NQ": "QLD", ...} + + returns: + path_id direction timeslice capacity + NNSW-SQ forward qld_peak_demand 950 + NNSW-SQ reverse nsw_peak_demand 1450 + Q1-NQ reverse qld_summer_typical 750 + SA-VIC (NaN) (NaN) (NaN) + + Raises ValueError if any path geo is absent from ``region_lookup`` (every + endpoint must resolve to a region, else its prefix would be NaN). + """ + _assert_geos_have_regions(paths, region_lookup) + merged = limits.merge( + paths[["path_id", "geo_from", "geo_to"]], on="path_id", how="left" + ) + tagged = merged["timeslice"].notna() + if not tagged.any(): + return merged[["path_id", "direction", "timeslice", "capacity"]] + endpoint = merged["geo_to"].where( + merged["direction"] == "forward", merged["geo_from"] + ) + lower_region = {geo: region.lower() for geo, region in region_lookup.items()} + merged.loc[tagged, "timeslice"] = ( + endpoint[tagged].map(lower_region) + "_" + merged.loc[tagged, "timeslice"] + ) + return merged[["path_id", "direction", "timeslice", "capacity"]] + + # --- Flow path extraction --- @@ -566,12 +667,12 @@ def _aggregate_to_nem_regions( Q1-NQ Q1 NQ AC # REZ, geo_to retargeted N1-CNSW N1 CNSW AC # REZ, geo_to retargeted - limits: - path_id direction timeslice capacity - CQ-NQ forward peak_demand 1200 - NNSW-SQ forward peak_demand 950 - Q1-NQ forward peak_demand 750 - N1-CNSW (NaN) (NaN) (NaN) + limits (already region-prefixed by ``_add_region_to_timeslices``): + path_id direction timeslice capacity + CQ-NQ forward qld_peak_demand 1200 + NNSW-SQ forward qld_peak_demand 950 + Q1-NQ forward qld_peak_demand 750 + N1-CNSW (NaN) (NaN) (NaN) region_lookup: {"NQ": "QLD", "CQ": "QLD", "NNSW": "NSW", "SQ": "QLD", @@ -587,18 +688,17 @@ def _aggregate_to_nem_regions( Q1-QLD Q1 QLD AC N1-NSW N1 NSW AC - returns limits: - path_id direction timeslice capacity - NSW-QLD forward peak_demand 950 # CQ-NQ row dropped - Q1-QLD forward peak_demand 750 - N1-NSW (NaN) (NaN) (NaN) # collapsed row preserved through rename + returns limits (path_ids re-keyed; the timeslice prefix is untouched): + path_id direction timeslice capacity + NSW-QLD forward qld_peak_demand 950 # CQ-NQ row dropped + Q1-QLD forward qld_peak_demand 750 + N1-NSW (NaN) (NaN) (NaN) # collapsed row preserved through rename - Raises ValueError if any flow-path or REZ-path geo is missing from - ``region_lookup`` (every geo must map to a real region). + Geo resolution is already validated upstream by ``_add_region_to_timeslices`` + (run before aggregation), so every geo here is known to map to a region. """ flow_paths = paths[~paths["geo_from"].isin(rez_ids)] rez_paths = paths[paths["geo_from"].isin(rez_ids)] - _validate_geos_have_regions(flow_paths, rez_paths, region_lookup) flow_paths = _filter_to_cross_region_flow_paths(flow_paths, region_lookup) new_flow_paths, flow_renames = _remap_flow_paths_to_regions( flow_paths, region_lookup @@ -629,10 +729,10 @@ def _aggregate_to_single_region( Q1-NQ Q1 NQ AC # REZ -> retargeted N1-CNSW N1 CNSW AC # REZ -> retargeted - limits: - path_id direction timeslice capacity - CQ-NQ forward peak_demand 1200 - Q1-NQ forward peak_demand 750 + limits (already region-prefixed by ``_add_region_to_timeslices``): + path_id direction timeslice capacity + CQ-NQ forward qld_peak_demand 1200 + Q1-NQ forward qld_peak_demand 750 rez_ids: {"Q1", "N1"} @@ -642,9 +742,9 @@ def _aggregate_to_single_region( Q1-NEM Q1 NEM AC N1-NEM N1 NEM AC - returns limits: - path_id direction timeslice capacity - Q1-NEM forward peak_demand 750 # CQ-NQ row dropped + returns limits (REZ keeps its own qld prefix though geo_to is now NEM): + path_id direction timeslice capacity + Q1-NEM forward qld_peak_demand 750 # CQ-NQ row dropped """ rez_paths = paths[paths["geo_from"].isin(rez_ids)] new_rez_paths, rename_map = _remap_rez_paths(rez_paths, _SINGLE_REGION_ID) @@ -652,25 +752,22 @@ def _aggregate_to_single_region( return new_rez_paths, new_limits -def _validate_geos_have_regions( - flow_paths: pd.DataFrame, - rez_paths: pd.DataFrame, +def _assert_geos_have_regions( + paths: pd.DataFrame, region_lookup: dict[str, str], ) -> None: - """Raises ValueError if any path geo is absent from ``region_lookup``. + """Raises ValueError if any path endpoint is absent from ``region_lookup``. - Every flow-path endpoint and every REZ ``geo_to`` (the parent sub-region) - must map to a NEM region. If a geo is missing, downstream mapping would - silently produce NaN and corrupt the output path IDs. + Every ``geo_from`` and ``geo_to`` — sub-region, REZ id, or already-aggregated + region — must resolve to a NEM region, because the timeslice region prefix and + the cross-region path re-keying both look the endpoint up. A missing geo would + otherwise produce a NaN prefix or corrupt the output path IDs silently. I/O Example: - flow_paths: + paths: path_id geo_from geo_to CQ-NQ CQ NQ MN-SA MN SA # MN, SA missing from region_lookup - - rez_paths: - path_id geo_from geo_to Q1-NQ Q1 NQ region_lookup: @@ -679,9 +776,7 @@ def _validate_geos_have_regions( raises: ValueError: Path geos missing from sub_regional_geography: ['MN', 'SA'] """ - geos = pd.concat( - [flow_paths["geo_from"], flow_paths["geo_to"], rez_paths["geo_to"]] - ).unique() + geos = pd.concat([paths["geo_from"], paths["geo_to"]]).unique() missing = sorted(set(geos) - set(region_lookup.keys())) if missing: raise ValueError(f"Path geos missing from sub_regional_geography: {missing}") @@ -824,18 +919,18 @@ def _remap_limit_path_ids( Inputs: limits: - path_id direction timeslice capacity - CQ-NQ forward peak_demand 1200 # not in rename_map -> dropped - NNSW-SQ forward peak_demand 950 - Q1-NQ forward peak_demand 750 + path_id direction timeslice capacity + CQ-NQ forward qld_peak_demand 1200 # not in rename_map -> dropped + NNSW-SQ forward qld_peak_demand 950 + Q1-NQ forward qld_peak_demand 750 rename_map: {"NNSW-SQ": "NSW-QLD", "Q1-NQ": "Q1-QLD"} returns: - path_id direction timeslice capacity - NSW-QLD forward peak_demand 950 - Q1-QLD forward peak_demand 750 + path_id direction timeslice capacity + NSW-QLD forward qld_peak_demand 950 + Q1-QLD forward qld_peak_demand 750 """ kept = limits[limits["path_id"].isin(rename_map.keys())].copy() kept["path_id"] = kept["path_id"].map(rename_map) @@ -851,6 +946,7 @@ def _append_new_parallel_paths( paths: pd.DataFrame, limits: pd.DataFrame, flow_path_options: dict[str, pd.DataFrame], + region_lookup: dict[str, str], ) -> tuple[pd.DataFrame, pd.DataFrame]: """Appends topology + limit rows for augmentation corridors not already in the path table. @@ -889,10 +985,10 @@ def _append_new_parallel_paths( CNSW-SNW_NTH CNSW SNW AC CNSW-SNW_STH CNSW SNW AC - limits (existing siblings, abbreviated): - path_id direction timeslice capacity - CNSW-SNW_NTH forward peak_demand 900 - CNSW-SNW_STH forward peak_demand 800 + limits (existing siblings, abbreviated; timeslices already region-prefixed): + path_id direction timeslice capacity + CNSW-SNW_NTH forward nsw_peak_demand 900 + CNSW-SNW_STH forward nsw_peak_demand 800 flow_path_options keys: {"CNSW-SNW"} # un-suffixed corridor @@ -902,19 +998,20 @@ def _append_new_parallel_paths( CNSW-SNW_STH CNSW SNW AC CNSW-SNW CNSW SNW AC - returns limits (six explicit-zero rows appended for CNSW-SNW: 2 directions x 3 timeslices): - path_id direction timeslice capacity - CNSW-SNW_NTH forward peak_demand 900 - CNSW-SNW_STH forward peak_demand 800 - CNSW-SNW forward peak_demand 0 - CNSW-SNW forward summer_typical 0 - CNSW-SNW forward winter_reference 0 - CNSW-SNW reverse peak_demand 0 - CNSW-SNW reverse summer_typical 0 - CNSW-SNW reverse winter_reference 0 + returns limits (six explicit-zero rows appended for CNSW-SNW: 2 directions x 3 timeslices. + CNSW and SNW are both NSW, so all six carry the nsw prefix): + path_id direction timeslice capacity + CNSW-SNW_NTH forward nsw_peak_demand 900 + CNSW-SNW_STH forward nsw_peak_demand 800 + CNSW-SNW forward nsw_peak_demand 0 + CNSW-SNW forward nsw_summer_typical 0 + CNSW-SNW forward nsw_winter_reference 0 + CNSW-SNW reverse nsw_peak_demand 0 + CNSW-SNW reverse nsw_summer_typical 0 + CNSW-SNW reverse nsw_winter_reference 0 """ new_paths, new_limits = _new_parallel_path_rows( - flow_path_options, set(paths["path_id"]) + flow_path_options, set(paths["path_id"]), region_lookup ) paths = pd.concat([paths, new_paths], ignore_index=True) limits = pd.concat([limits, new_limits], ignore_index=True) @@ -924,25 +1021,29 @@ def _append_new_parallel_paths( def _new_parallel_path_rows( flow_path_options: dict[str, pd.DataFrame], existing_path_ids: set[str], + region_lookup: dict[str, str], ) -> tuple[pd.DataFrame, pd.DataFrame]: """Builds topology + zero-capacity limit rows for augmentation keys without an existing path. Limits are explicit zeros (not NaN) because these paths physically don't exist yet — NaN in this schema means "translator applies default capacity", which - would let the model dispatch flow on a Link that hasn't been built. + would let the model dispatch flow on a Link that hasn't been built. Timeslices + are region-prefixed by ``_add_region_to_timeslices`` so the new corridor matches + the rest of the limits table. I/O Example: flow_path_options keys: {"CQ-NQ", "CNSW-SNW"} existing_path_ids: {"CQ-NQ", "CNSW-SNW_NTH", "CNSW-SNW_STH"} + region_lookup: {"CNSW": "NSW", "SNW": "NSW", ...} returns: paths: path_id geo_from geo_to carrier CNSW-SNW CNSW SNW AC - limits (6 rows: 2 directions x 3 timeslices, all 0 MW): - path_id direction timeslice capacity - CNSW-SNW forward peak_demand 0 - CNSW-SNW forward summer_typical 0 + limits (6 rows: 2 directions x 3 timeslices, all 0 MW, nsw-prefixed): + path_id direction timeslice capacity + CNSW-SNW forward nsw_peak_demand 0 + CNSW-SNW forward nsw_summer_typical 0 ... etc """ new_keys = sorted(set(flow_path_options.keys()) - existing_path_ids) @@ -965,6 +1066,7 @@ def _new_parallel_path_rows( ], columns=["path_id", "direction", "timeslice", "capacity"], ).astype({"capacity": "float64"}) + limits = _add_region_to_timeslices(limits, paths, region_lookup) return paths, limits diff --git a/tests/test_templater/test_create_ispypsa_inputs_template.py b/tests/test_templater/test_create_ispypsa_inputs_template.py index b1227eb0..ff8f9947 100644 --- a/tests/test_templater/test_create_ispypsa_inputs_template.py +++ b/tests/test_templater/test_create_ispypsa_inputs_template.py @@ -152,10 +152,13 @@ def test_create_ispypsa_inputs_template_single_regions( # per-module templater tests. def test_create_ispypsa_inputs_template_new_format(csv_str_to_df): # SNW is included alongside NQ and CNSW so the parallel-path scenario below - # (CNSW-SNW corridor) has valid endpoints in the geography. + # (CNSW-SNW corridor) has valid endpoints in the geography. CQ is included so + # the CQ-NQ flow path's region prefix resolves (every flow-path endpoint must + # appear in the geography). sub_regional_reference_nodes = csv_str_to_df(""" NEM region, ISP sub-region, Sub-regional reference node Queensland, Northern Queensland (NQ), Ross 275 kV + Queensland, Central Queensland (CQ), Stanwell 275 kV New South Wales, Central New South Wales (CNSW), Wellington 330 kV New South Wales, Southern New South Wales (SNW), Lower Tumut 330 kV """) @@ -254,8 +257,8 @@ def test_create_ispypsa_inputs_template_new_format(csv_str_to_df): geography = result["network_geography"] assert set(geography.columns) == {"geo_id", "geo_type", "region_id", "subregion_id"} - # 3 subregions (NQ + CNSW + SNW) + 2 REZs. - assert len(geography) == 5 + # 4 subregions (NQ + CQ + CNSW + SNW) + 2 REZs. + assert len(geography) == 6 paths = result["network_transmission_paths"] assert set(paths.columns) == {"path_id", "geo_from", "geo_to", "carrier"} @@ -473,9 +476,12 @@ def test_create_ispypsa_inputs_template_new_format_nem_regions(csv_str_to_df): def test_create_ispypsa_inputs_template_new_format_single_region(csv_str_to_df): + # CQ is included so the CQ-NQ flow path's region prefix resolves at the + # sub-regional level (prefixing runs before single_region drops flow paths). sub_regional_reference_nodes = csv_str_to_df(""" NEM region, ISP sub-region, Sub-regional reference node Queensland, Northern Queensland (NQ), Ross 275 kV + Queensland, Central Queensland (CQ), Stanwell 275 kV New South Wales, Central New South Wales (CNSW), Wellington 330 kV """) renewable_energy_zones = csv_str_to_df(""" diff --git a/tests/test_templater/test_transmission.py b/tests/test_templater/test_transmission.py index 84a0742d..720b5a2c 100644 --- a/tests/test_templater/test_transmission.py +++ b/tests/test_templater/test_transmission.py @@ -27,8 +27,34 @@ ] -def _empty_sub_regional_geography(): - return pd.DataFrame(columns=["geo_id", "geo_type", "region_id", "subregion_id"]) +def _sub_regional_geography(): + """Geography covering every sub-region and REZ used by this module's tests. + + The geo_id -> region_id mapping drives the region prefix on each limit's + timeslice (see ``transmission._add_region_to_timeslices``), so every flow-path + endpoint and REZ here must be present or the templater raises. + """ + rows = [ + ("NQ", "subregion", "QLD", "NQ"), + ("CQ", "subregion", "QLD", "CQ"), + ("SQ", "subregion", "QLD", "SQ"), + ("NNSW", "subregion", "NSW", "NNSW"), + ("CNSW", "subregion", "NSW", "CNSW"), + ("SNW", "subregion", "NSW", "SNW"), + ("SEV", "subregion", "VIC", "SEV"), + ("WNV", "subregion", "VIC", "WNV"), + ("VIC", "subregion", "VIC", "VIC"), + ("CSA", "subregion", "SA", "CSA"), + ("SA", "subregion", "SA", "SA"), + ("MN", "subregion", "SA", "MN"), + ("TAS", "subregion", "TAS", "TAS"), + ("Q1", "rez", "QLD", "NQ"), + ("Q3", "rez", "QLD", "NQ"), + ("N3", "rez", "NSW", "CNSW"), + ] + return pd.DataFrame( + rows, columns=["geo_id", "geo_type", "region_id", "subregion_id"] + ) def test_template_network_transmission(csv_str_to_df): @@ -60,7 +86,7 @@ def test_template_network_transmission(csv_str_to_df): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -82,50 +108,54 @@ def test_template_network_transmission(csv_str_to_df): expected_paths.sort_values("path_id").reset_index(drop=True), ) + # Timeslices are region-prefixed: forward carries the destination region's + # demand condition, reverse the origin's (Open-ISP/ISPyPSA#109). Intra-region + # paths (CQ-NQ, CNSW-SNW_NTH) and REZs (Q1-NQ) get one region both ways; + # cross-region paths (NNSW-SQ, TAS-SEV, WNV-CSA) differ by direction. expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - CQ-NQ, forward, peak_demand, 1200 - CQ-NQ, forward, summer_typical, 1200 - CQ-NQ, forward, winter_reference, 1400 - CQ-NQ, reverse, peak_demand, 1440 - CQ-NQ, reverse, summer_typical, 1440 - CQ-NQ, reverse, winter_reference, 1910 - NNSW-SQ, forward, peak_demand, 950 - NNSW-SQ, forward, summer_typical, 950 - NNSW-SQ, forward, winter_reference, 950 - NNSW-SQ, reverse, peak_demand, 1450 - NNSW-SQ, reverse, summer_typical, 1450 - NNSW-SQ, reverse, winter_reference, 1450 - NNSW-SQ_Terranora, forward, peak_demand, 0 - NNSW-SQ_Terranora, forward, summer_typical, 50 - NNSW-SQ_Terranora, forward, winter_reference, 50 - NNSW-SQ_Terranora, reverse, peak_demand, 130 - NNSW-SQ_Terranora, reverse, summer_typical, 150 - NNSW-SQ_Terranora, reverse, winter_reference, 200 - TAS-SEV, forward, peak_demand, 594 - TAS-SEV, forward, summer_typical, 594 - TAS-SEV, forward, winter_reference, 594 - TAS-SEV, reverse, peak_demand, 478 - TAS-SEV, reverse, summer_typical, 478 - TAS-SEV, reverse, winter_reference, 478 - WNV-CSA_Murraylink, forward, peak_demand, 165 - WNV-CSA_Murraylink, forward, summer_typical, 220 - WNV-CSA_Murraylink, forward, winter_reference, 220 - WNV-CSA_Murraylink, reverse, peak_demand, 100 - WNV-CSA_Murraylink, reverse, summer_typical, 200 - WNV-CSA_Murraylink, reverse, winter_reference, 200 - CNSW-SNW_NTH, forward, peak_demand, 4490 - CNSW-SNW_NTH, forward, summer_typical, 4490 - CNSW-SNW_NTH, forward, winter_reference, 4730 - CNSW-SNW_NTH, reverse, peak_demand, 4490 - CNSW-SNW_NTH, reverse, summer_typical, 4490 - CNSW-SNW_NTH, reverse, winter_reference, 4730 - Q1-NQ, forward, peak_demand, 750 - Q1-NQ, forward, summer_typical, 750 - Q1-NQ, forward, winter_reference, 750 - Q1-NQ, reverse, peak_demand, 750 - Q1-NQ, reverse, summer_typical, 750 - Q1-NQ, reverse, winter_reference, 750 + path_id, direction, timeslice, capacity + CQ-NQ, forward, qld_peak_demand, 1200 + CQ-NQ, forward, qld_summer_typical, 1200 + CQ-NQ, forward, qld_winter_reference, 1400 + CQ-NQ, reverse, qld_peak_demand, 1440 + CQ-NQ, reverse, qld_summer_typical, 1440 + CQ-NQ, reverse, qld_winter_reference, 1910 + NNSW-SQ, forward, qld_peak_demand, 950 + NNSW-SQ, forward, qld_summer_typical, 950 + NNSW-SQ, forward, qld_winter_reference, 950 + NNSW-SQ, reverse, nsw_peak_demand, 1450 + NNSW-SQ, reverse, nsw_summer_typical, 1450 + NNSW-SQ, reverse, nsw_winter_reference, 1450 + NNSW-SQ_Terranora, forward, qld_peak_demand, 0 + NNSW-SQ_Terranora, forward, qld_summer_typical, 50 + NNSW-SQ_Terranora, forward, qld_winter_reference, 50 + NNSW-SQ_Terranora, reverse, nsw_peak_demand, 130 + NNSW-SQ_Terranora, reverse, nsw_summer_typical, 150 + NNSW-SQ_Terranora, reverse, nsw_winter_reference, 200 + TAS-SEV, forward, vic_peak_demand, 594 + TAS-SEV, forward, vic_summer_typical, 594 + TAS-SEV, forward, vic_winter_reference, 594 + TAS-SEV, reverse, tas_peak_demand, 478 + TAS-SEV, reverse, tas_summer_typical, 478 + TAS-SEV, reverse, tas_winter_reference, 478 + WNV-CSA_Murraylink, forward, sa_peak_demand, 165 + WNV-CSA_Murraylink, forward, sa_summer_typical, 220 + WNV-CSA_Murraylink, forward, sa_winter_reference, 220 + WNV-CSA_Murraylink, reverse, vic_peak_demand, 100 + WNV-CSA_Murraylink, reverse, vic_summer_typical, 200 + WNV-CSA_Murraylink, reverse, vic_winter_reference, 200 + CNSW-SNW_NTH, forward, nsw_peak_demand, 4490 + CNSW-SNW_NTH, forward, nsw_summer_typical, 4490 + CNSW-SNW_NTH, forward, nsw_winter_reference, 4730 + CNSW-SNW_NTH, reverse, nsw_peak_demand, 4490 + CNSW-SNW_NTH, reverse, nsw_summer_typical, 4490 + CNSW-SNW_NTH, reverse, nsw_winter_reference, 4730 + Q1-NQ, forward, qld_peak_demand, 750 + Q1-NQ, forward, qld_summer_typical, 750 + Q1-NQ, forward, qld_winter_reference, 750 + Q1-NQ, reverse, qld_peak_demand, 750 + Q1-NQ, reverse, qld_summer_typical, 750 + Q1-NQ, reverse, qld_winter_reference, 750 SA-VIC, , , Q3-NQ, , , N3-CNSW, , , @@ -158,18 +188,18 @@ def test_typo_in_column_names(csv_str_to_df): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - CQ-NQ, forward, peak_demand, 100 - CQ-NQ, forward, summer_typical, 200 - CQ-NQ, forward, winter_reference, 300 - CQ-NQ, reverse, peak_demand, 400 - CQ-NQ, reverse, summer_typical, 500 - CQ-NQ, reverse, winter_reference, 600 + path_id, direction, timeslice, capacity + CQ-NQ, forward, qld_peak_demand, 100 + CQ-NQ, forward, qld_summer_typical, 200 + CQ-NQ, forward, qld_winter_reference, 300 + CQ-NQ, reverse, qld_peak_demand, 400 + CQ-NQ, reverse, qld_summer_typical, 500 + CQ-NQ, reverse, qld_winter_reference, 600 """) pd.testing.assert_frame_equal( limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( @@ -200,7 +230,7 @@ def test_empty_flow_paths(csv_str_to_df): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -214,13 +244,13 @@ def test_empty_flow_paths(csv_str_to_df): ) expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - Q1-NQ, forward, peak_demand, 750 - Q1-NQ, forward, summer_typical, 750 - Q1-NQ, forward, winter_reference, 750 - Q1-NQ, reverse, peak_demand, 750 - Q1-NQ, reverse, summer_typical, 750 - Q1-NQ, reverse, winter_reference, 750 + path_id, direction, timeslice, capacity + Q1-NQ, forward, qld_peak_demand, 750 + Q1-NQ, forward, qld_summer_typical, 750 + Q1-NQ, forward, qld_winter_reference, 750 + Q1-NQ, reverse, qld_peak_demand, 750 + Q1-NQ, reverse, qld_summer_typical, 750 + Q1-NQ, reverse, qld_winter_reference, 750 """) pd.testing.assert_frame_equal( limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( @@ -249,7 +279,7 @@ def test_empty_rez(csv_str_to_df): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -263,13 +293,13 @@ def test_empty_rez(csv_str_to_df): ) expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - CQ-NQ, forward, peak_demand, 100 - CQ-NQ, forward, summer_typical, 200 - CQ-NQ, forward, winter_reference, 300 - CQ-NQ, reverse, peak_demand, 400 - CQ-NQ, reverse, summer_typical, 500 - CQ-NQ, reverse, winter_reference, 600 + path_id, direction, timeslice, capacity + CQ-NQ, forward, qld_peak_demand, 100 + CQ-NQ, forward, qld_summer_typical, 200 + CQ-NQ, forward, qld_winter_reference, 300 + CQ-NQ, reverse, qld_peak_demand, 400 + CQ-NQ, reverse, qld_summer_typical, 500 + CQ-NQ, reverse, qld_winter_reference, 600 """) pd.testing.assert_frame_equal( limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( @@ -294,7 +324,7 @@ def test_both_empty(csv_str_to_df): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -329,7 +359,7 @@ def test_logs_flow_paths_with_no_capacity_data(csv_str_to_df, caplog): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -354,7 +384,7 @@ def test_no_log_when_all_flow_paths_have_capacity_data(csv_str_to_df, caplog): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -379,7 +409,7 @@ def test_logs_rez_paths_absent_from_initial_limits(csv_str_to_df, caplog): flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -405,7 +435,7 @@ def test_no_log_when_all_rez_paths_present_in_initial_limits(csv_str_to_df, capl flow_paths, initial_limits, renewable_energy_zones, - _empty_sub_regional_geography(), + _sub_regional_geography(), "sub_regions", ) @@ -580,9 +610,10 @@ def test_new_parallel_path_rows_picks_up_keys_without_existing_path(csv_str_to_d "CNSW-SNW": pd.DataFrame(), # new parallel path } existing_path_ids = {"CQ-NQ", "CNSW-SNW_NTH", "CNSW-SNW_STH"} + region_lookup = {"CNSW": "NSW", "SNW": "NSW"} new_paths, new_limits = _new_parallel_path_rows( - flow_path_options, existing_path_ids + flow_path_options, existing_path_ids, region_lookup ) expected_paths = csv_str_to_df(""" @@ -594,15 +625,16 @@ def test_new_parallel_path_rows_picks_up_keys_without_existing_path(csv_str_to_d expected_paths.reset_index(drop=True), check_dtype=False, ) - # 6 zero-capacity rows: 2 directions x 3 timeslices. + # 6 zero-capacity rows: 2 directions x 3 timeslices. CNSW and SNW are both NSW, + # so every row is nsw-prefixed regardless of direction. expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - CNSW-SNW, forward, peak_demand, 0 - CNSW-SNW, forward, summer_typical, 0 - CNSW-SNW, forward, winter_reference, 0 - CNSW-SNW, reverse, peak_demand, 0 - CNSW-SNW, reverse, summer_typical, 0 - CNSW-SNW, reverse, winter_reference, 0 + path_id, direction, timeslice, capacity + CNSW-SNW, forward, nsw_peak_demand, 0 + CNSW-SNW, forward, nsw_summer_typical, 0 + CNSW-SNW, forward, nsw_winter_reference, 0 + CNSW-SNW, reverse, nsw_peak_demand, 0 + CNSW-SNW, reverse, nsw_summer_typical, 0 + CNSW-SNW, reverse, nsw_winter_reference, 0 """) pd.testing.assert_frame_equal( new_limits.sort_values(["direction", "timeslice"]).reset_index(drop=True), @@ -643,11 +675,12 @@ def test_append_new_parallel_paths_no_new_keys_is_a_silent_noop(csv_str_to_df): CQ-NQ, forward, peak_demand, 1200.0 """) flow_path_options = {"CQ-NQ": pd.DataFrame()} + region_lookup = {"CQ": "QLD", "NQ": "QLD"} with warnings.catch_warnings(): warnings.simplefilter("error") result_paths, result_limits = _append_new_parallel_paths( - paths, limits, flow_path_options + paths, limits, flow_path_options, region_lookup ) pd.testing.assert_frame_equal(result_paths, paths) diff --git a/tests/test_templater/test_transmission_nem_regions.py b/tests/test_templater/test_transmission_nem_regions.py index cc68c062..1efb0aae 100644 --- a/tests/test_templater/test_transmission_nem_regions.py +++ b/tests/test_templater/test_transmission_nem_regions.py @@ -72,33 +72,35 @@ def test_nem_regions_filters_intra_region_paths_and_rekeys(csv_str_to_df): expected_paths.sort_values("path_id").reset_index(drop=True), ) - # CQ-NQ is dropped (intra-QLD); NNSW-SQ becomes NSW-QLD; etc. + # CQ-NQ is dropped (intra-QLD); NNSW-SQ becomes NSW-QLD; etc. Timeslices are + # region-prefixed: forward carries the destination region, reverse the origin. + # TAS-VIC is the asymmetric cross-region case (forward into VIC, reverse into TAS). expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - NSW-QLD, forward, peak_demand, 950 - NSW-QLD, forward, summer_typical, 950 - NSW-QLD, forward, winter_reference, 950 - NSW-QLD, reverse, peak_demand, 1450 - NSW-QLD, reverse, summer_typical, 1450 - NSW-QLD, reverse, winter_reference, 1450 - NSW-QLD_Terranora, forward, peak_demand, 0 - NSW-QLD_Terranora, forward, summer_typical, 50 - NSW-QLD_Terranora, forward, winter_reference, 50 - NSW-QLD_Terranora, reverse, peak_demand, 130 - NSW-QLD_Terranora, reverse, summer_typical, 150 - NSW-QLD_Terranora, reverse, winter_reference, 200 - TAS-VIC, forward, peak_demand, 594 - TAS-VIC, forward, summer_typical, 594 - TAS-VIC, forward, winter_reference, 594 - TAS-VIC, reverse, peak_demand, 478 - TAS-VIC, reverse, summer_typical, 478 - TAS-VIC, reverse, winter_reference, 478 - Q1-QLD, forward, peak_demand, 750 - Q1-QLD, forward, summer_typical, 750 - Q1-QLD, forward, winter_reference, 750 - Q1-QLD, reverse, peak_demand, 750 - Q1-QLD, reverse, summer_typical, 750 - Q1-QLD, reverse, winter_reference, 750 + path_id, direction, timeslice, capacity + NSW-QLD, forward, qld_peak_demand, 950 + NSW-QLD, forward, qld_summer_typical, 950 + NSW-QLD, forward, qld_winter_reference, 950 + NSW-QLD, reverse, nsw_peak_demand, 1450 + NSW-QLD, reverse, nsw_summer_typical, 1450 + NSW-QLD, reverse, nsw_winter_reference, 1450 + NSW-QLD_Terranora, forward, qld_peak_demand, 0 + NSW-QLD_Terranora, forward, qld_summer_typical, 50 + NSW-QLD_Terranora, forward, qld_winter_reference, 50 + NSW-QLD_Terranora, reverse, nsw_peak_demand, 130 + NSW-QLD_Terranora, reverse, nsw_summer_typical, 150 + NSW-QLD_Terranora, reverse, nsw_winter_reference, 200 + TAS-VIC, forward, vic_peak_demand, 594 + TAS-VIC, forward, vic_summer_typical, 594 + TAS-VIC, forward, vic_winter_reference, 594 + TAS-VIC, reverse, tas_peak_demand, 478 + TAS-VIC, reverse, tas_summer_typical, 478 + TAS-VIC, reverse, tas_winter_reference, 478 + Q1-QLD, forward, qld_peak_demand, 750 + Q1-QLD, forward, qld_summer_typical, 750 + Q1-QLD, forward, qld_winter_reference, 750 + Q1-QLD, reverse, qld_peak_demand, 750 + Q1-QLD, reverse, qld_summer_typical, 750 + Q1-QLD, reverse, qld_winter_reference, 750 """) pd.testing.assert_frame_equal( limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( diff --git a/tests/test_templater/test_transmission_single_region.py b/tests/test_templater/test_transmission_single_region.py index 3d3dae85..c29fbea9 100644 --- a/tests/test_templater/test_transmission_single_region.py +++ b/tests/test_templater/test_transmission_single_region.py @@ -40,7 +40,13 @@ def test_single_region_drops_flow_paths_and_rekeys_rez(csv_str_to_df): """) sub_regional_geography = csv_str_to_df(""" - geo_id, geo_type, region_id, subregion_id + geo_id, geo_type, region_id, subregion_id + CQ, subregion, QLD, CQ + NQ, subregion, QLD, NQ + SQ, subregion, QLD, SQ + NNSW, subregion, NSW, NNSW + Q1, rez, QLD, NQ + N1, rez, NSW, NNSW """) paths, limits = _template_network_transmission( @@ -61,15 +67,17 @@ def test_single_region_drops_flow_paths_and_rekeys_rez(csv_str_to_df): expected_paths.sort_values("path_id").reset_index(drop=True), ) - # CQ-NQ and NNSW-SQ flow path limits are dropped; only REZ limits remain. + # CQ-NQ and NNSW-SQ flow path limits are dropped; only REZ limits remain. The + # REZ keeps its own region prefix (qld) even though geo_to is now the NEM geo — + # the prefix is fixed at the sub-regional level before aggregation. expected_limits = csv_str_to_df(""" - path_id, direction, timeslice, capacity - Q1-NEM, forward, peak_demand, 750 - Q1-NEM, forward, summer_typical, 750 - Q1-NEM, forward, winter_reference, 750 - Q1-NEM, reverse, peak_demand, 750 - Q1-NEM, reverse, summer_typical, 750 - Q1-NEM, reverse, winter_reference, 750 + path_id, direction, timeslice, capacity + Q1-NEM, forward, qld_peak_demand, 750 + Q1-NEM, forward, qld_summer_typical, 750 + Q1-NEM, forward, qld_winter_reference, 750 + Q1-NEM, reverse, qld_peak_demand, 750 + Q1-NEM, reverse, qld_summer_typical, 750 + Q1-NEM, reverse, qld_winter_reference, 750 N1-NEM, , , """) pd.testing.assert_frame_equal( @@ -131,7 +139,9 @@ def test_single_region_rez_with_no_initial_limit_keeps_collapsed_row(csv_str_to_ """) sub_regional_geography = csv_str_to_df(""" - geo_id, geo_type, region_id, subregion_id + geo_id, geo_type, region_id, subregion_id + NNSW, subregion, NSW, NNSW + N1, rez, NSW, NNSW """) paths, limits = _template_network_transmission( From 926eff36f33c174a628ca2c7e01d786c314480b4 Mon Sep 17 00:00:00 2001 From: nick-gorman Date: Thu, 4 Jun 2026 12:19:36 +1000 Subject: [PATCH 2/3] Test region prefix on a corridor injected after aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The region->region identities in _build_geo_region_lookup were unexercised. The documented parallel-path injection case (CNSW-SNW) is intra-region, so it drops out at nem_regions and nothing reached _add_region_to_timeslices with endpoints that are already NEM regions. This pins that path: a cross-region corridor whose only base sibling is suffixed is injected at nem_regions and must still read forward=qld, reverse=nsw — which needs the identities to resolve NSW/QLD. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_transmission_nem_regions.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_templater/test_transmission_nem_regions.py b/tests/test_templater/test_transmission_nem_regions.py index 1efb0aae..474dce3f 100644 --- a/tests/test_templater/test_transmission_nem_regions.py +++ b/tests/test_templater/test_transmission_nem_regions.py @@ -298,3 +298,77 @@ def test_nem_regions_raises_when_rez_parent_subregion_missing_from_geography( sub_regional_geography, "nem_regions", ) + + +def test_nem_regions_new_parallel_corridor_gets_region_prefix(csv_str_to_df): + """A parallel corridor injected after aggregation is still region-prefixed. + + ``_append_new_parallel_paths`` runs after the paths have been re-keyed to NEM + regions, so the injected corridor's endpoints are already regions (NSW, QLD), + not sub-regions. Prefixing them relies on the region->region identities in the + geo lookup. Here only the suffixed sibling (NNSW-SQ Terranora -> NSW-QLD_Terranora) + has a base path, so the un-suffixed ``NSW-QLD`` augmentation key has no match and + is injected as a zero-capacity corridor — and must still read forward=qld, + reverse=nsw. + """ + flow_paths = csv_str_to_df(""" + Flow Paths, Forward direction capability approximation (MW)_Peak demand, Forward direction capability approximation (MW)_Summer typical, Forward direction capability approximation (MW)_Winter reference, Reverse direction capability approximation (MW)_Peak demand, Reverse direction capability approximation (MW)_Summer typical, Reverse direction capability approximation (MW)_Winter reference + NNSW-SQ (Terranora), 50, 50, 50, 50, 50, 50 + """) + initial_limits = pd.DataFrame(columns=_REZ_LIMIT_COLUMNS) + renewable_energy_zones = csv_str_to_df(""" + ID, Name, NEM region, ISP sub-region + """) + sub_regional_geography = csv_str_to_df(""" + geo_id, geo_type, region_id, subregion_id + NNSW, subregion, NSW, NNSW + SQ, subregion, QLD, SQ + """) + # Region-keyed augmentation corridor (as create_template's granularity filter + # produces at nem_regions) with no matching aggregated path. + flow_path_options = {"NSW-QLD": pd.DataFrame()} + + paths, limits = _template_network_transmission( + flow_paths, + initial_limits, + renewable_energy_zones, + sub_regional_geography, + "nem_regions", + flow_path_options, + ) + + expected_paths = csv_str_to_df(""" + path_id, geo_from, geo_to, carrier + NSW-QLD_Terranora, NSW, QLD, DC + NSW-QLD, NSW, QLD, AC + """) + pd.testing.assert_frame_equal( + paths.sort_values("path_id").reset_index(drop=True), + expected_paths.sort_values("path_id").reset_index(drop=True), + ) + + expected_limits = csv_str_to_df(""" + path_id, direction, timeslice, capacity + NSW-QLD_Terranora, forward, qld_peak_demand, 50 + NSW-QLD_Terranora, forward, qld_summer_typical, 50 + NSW-QLD_Terranora, forward, qld_winter_reference, 50 + NSW-QLD_Terranora, reverse, nsw_peak_demand, 50 + NSW-QLD_Terranora, reverse, nsw_summer_typical, 50 + NSW-QLD_Terranora, reverse, nsw_winter_reference, 50 + NSW-QLD, forward, qld_peak_demand, 0 + NSW-QLD, forward, qld_summer_typical, 0 + NSW-QLD, forward, qld_winter_reference, 0 + NSW-QLD, reverse, nsw_peak_demand, 0 + NSW-QLD, reverse, nsw_summer_typical, 0 + NSW-QLD, reverse, nsw_winter_reference, 0 + """) + pd.testing.assert_frame_equal( + limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( + drop=True + ), + expected_limits.sort_values(["path_id", "direction", "timeslice"]).reset_index( + drop=True + ), + check_exact=False, + check_dtype=False, + ) From 4e9366dcce987a14e563417cfeb6909eca6675c1 Mon Sep 17 00:00:00 2001 From: nick-gorman Date: Thu, 4 Jun 2026 12:52:17 +1000 Subject: [PATCH 3/3] Document PR-description style in CLAUDE.md Captures the conventions used for this PR so future descriptions are consistent: lead with the story, include a directory-tree diagram, flag the non-obvious design choices, drop sections that say nothing distinctive, and keep the tone dry and understated rather than effusive. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d854ad8c..76b3a5d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,3 +291,26 @@ uv run pre-commit run --all-files # Run linters - Before staging, list the files you intend to commit and confirm with the user. Local-only edits (dev configs, feature flag flips, experiment artefacts) often sit alongside the real change and should not be swept into the commit. + +### Pull request descriptions + +The description orients the reviewer. After reading the opening, and before opening any +file, they should know what the change is and what to expect from the diff. + +- **Lead with the story, not the mechanics.** Open with why the change exists and what it + does conceptually — the problem, the decision, the shape of the fix. Leave the + helper-by-helper detail to the code. +- **Include a directory-tree diagram** of where the key changes live, with a short arrow + note against each path, so the reviewer sees the lay of the land before diving in. +- **Flag the design choices a reviewer would otherwise have to reverse-engineer** — the + non-obvious "why it's done this way", not a changelog of every edit. +- **Drop sections with nothing distinctive to say.** A "Testing" section that only says + "ran the tests" is noise; omit it. Include what's specific to this change. +- **Cut detail that doesn't earn its place.** Test-fixture tweaks, incidental renames and + the like belong in the diff, not the orientation. +- **State relationships accurately, without overstating.** Describe what the change actually + does; don't reach for a stronger claim than is true. + +Tone: concise, direct, conversational; British/Australian English; understated rather than +effusive — no hyperbole. Don't anthropomorphise the PR: "this change adds X" / "X now does +Y", not "this PR teaches X".