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". 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..474dce3f 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( @@ -296,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, + ) 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(