Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/ispypsa/templater/create_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
_template_sub_regional_flow_path_costs,
_template_sub_regional_flow_paths,
)
from ispypsa.templater.geography import _template_network_geography
from ispypsa.templater.geography import (
_add_non_rez_geographies_to_rez_table,
_template_network_geography,
)
from ispypsa.templater.helpers import _build_geo_region_lookup
from ispypsa.templater.network_expansion import (
_extract_flow_path_costs_from_iasr,
Expand Down Expand Up @@ -184,6 +187,8 @@ def create_ispypsa_inputs_template(
# templater path) and inline this branch.
if FEATURE_FLAGS["use_new_table_format"]:
template = {}
# Explicitly add N0/V0 rows to the 'renewable_energy_zones' IASR table
iasr_tables = _add_non_rez_geographies_to_rez_table(iasr_tables)
sub_regional_geography = _template_network_geography(
iasr_tables["sub_regional_reference_nodes"],
iasr_tables["renewable_energy_zones"],
Expand Down
7 changes: 7 additions & 0 deletions src/ispypsa/templater/existing_planned.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@
column="IASR ID",
replacements={"KiataWF1": "KIATAWF1"},
),
# Sub-REZ IDs Q8(a/b/c) in ECAA summary table 'REZ ID' column are not real REZs,
# but IDs used for resource-quality based build limits inside the Q8 REZ.
dict(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a silly question, but is the reason that this works because the resource traces for the assocaited units are all mapped via their IASR ID, and while the a, b, and c areas are resource quality areas, they don't actuall get used for mapping to resource traces?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at all, and yep - it's IASR ID based mapping for the resource traces (pretty much - I think there are some typos/slightly differently-named traces but the new config setup in the trace-parser also has an 'iasr_aliases' field to cover those cases too).

It is pretty similar for the new entrants too - not exactly the same ordering of name/id components but the traces are named like 'REZ_Q8a_Darling_Downs_SAT...', for the corresponding templated new entrant 'name'='Q8a_SAT_Darling Downs'. I thought about alternatives, but they'd have flow-on effects that I didn't feel made them worth it (yet, at least). Still leaving the related issue open to come back to cause I don't think it's 100% perfectly resolved, but this works for now I think...

table_name="existing_committed_anticipated_additional_generator_summary",
column="REZ ID",
replacements={"Q8a": "Q8", "Q8b": "Q8", "Q8c": "Q8"},
),
]

# --- public orchestrators ---
Expand Down
74 changes: 73 additions & 1 deletion src/ispypsa/templater/geography.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import logging

import pandas as pd

from ispypsa.templater.mappings import _NEM_REGION_IDS, _SINGLE_REGION_ID
from ispypsa.templater.mappings import (
_NEM_REGION_IDS,
_NON_REZ_GEOGRAPHIES,
_SINGLE_REGION_ID,
)


def _template_network_geography(
Expand Down Expand Up @@ -274,3 +280,69 @@ def _rez_rows_for_single_region(
def _extract_subregion_id(series: pd.Series) -> pd.Series:
"""Extract subregion ID from 'Name (ID)' format, e.g. 'Northern Queensland (NQ)' -> 'NQ'."""
return series.str.extract(r"\(([A-Z]+)\)", expand=False)


# --- non-REZ geographies ---


def _add_non_rez_geographies_to_rez_table(
iasr_tables: dict[str, pd.DataFrame],
) -> dict[str, pd.DataFrame]:
"""Returns a shallow copy of iasr_tables with non-REZ areas N0 and V0 appended
to renewable_energy_zones.

Raises:
ValueError: if any non-REZ ID is already present in renewable_energy_zones.

I/O Example:
_NON_REZ_GEOGRAPHIES (constant, mappings.py):
ID, Name, NEM region, ISP sub-region
N0, New South Wales Non-REZ, NSW, CNSW
V0, Victoria Non-REZ, VIC, SEV

iasr_tables["renewable_energy_zones"]:
ID, Name, NEM region, ISP sub-region
Q1, Far North, QLD, NQ
N3, Central-West, NSW, CNSW

returns:
iasr_tables["renewable_energy_zones"]:
ID, Name, NEM region, ISP sub-region
Q1, Far North, QLD, NQ
N3, Central-West, NSW, CNSW
N0, New South Wales Non-REZ, NSW, CNSW # added
V0, Victoria Non-REZ, VIC, SEV # added

All other iasr_tables are returned unchanged.
"""
_assert_non_rezs_dont_exist(iasr_tables["renewable_energy_zones"])
table = pd.concat(
[iasr_tables["renewable_energy_zones"], _NON_REZ_GEOGRAPHIES],
axis=0,
ignore_index=True,
)
_log_non_rezs_added_to_rez_table()
return {**iasr_tables, "renewable_energy_zones": table}


def _assert_non_rezs_dont_exist(table: pd.DataFrame) -> None:
"""Raises if any non-REZ ID is already in the table, which would duplicate it."""
additional_non_rez_ids = set(_NON_REZ_GEOGRAPHIES["ID"])
existing_ids = set(table["ID"])
already_present = additional_non_rez_ids & existing_ids
if already_present:
msg = (
f"Non-REZ ID(s) {sorted(already_present)} are already defined in "
"'renewable_energy_zones' IASR table. Check '_NON_REZ_GEOGRAPHIES' "
"is up-to-date."
)
raise ValueError(msg)


def _log_non_rezs_added_to_rez_table() -> None:
"""Logs non-REZs added to the renewable_energy_zones table."""
msg = (
"IASR TABLE EDIT: Added non-REZ geographies to 'renewable_energy_zones' "
f"IASR table: {sorted(_NON_REZ_GEOGRAPHIES['ID'])}"
)
logging.info(msg)
23 changes: 23 additions & 0 deletions src/ispypsa/templater/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,29 @@
# lowercased NEM region id.
_CANONICAL_TIMESLICES = ("peak_demand", "summer_typical", "winter_reference")

# Two non-REZ areas are defined in the ISP to host lower resource quality new
# entrant VRE (see IASR 2026, p.164 for ISP reasoning). They are not present in the
# 'renewable_energy_zones' table in the IASR workbook, so they get manually added
# by `_add_non_rez_geographies_to_rez_table` before templating, to ensure
# the corresponding geo_ids exist and are mapped to the correct sub/NEM regions.
# See Open-ISP/ISPyPSA#133.
_NON_REZ_GEOGRAPHIES = pd.DataFrame(
[
{
"ID": "N0",
"Name": "New South Wales Non-REZ",
"NEM region": "NSW",
"ISP sub-region": "CNSW",
},
{
"ID": "V0",
"Name": "Victoria Non-REZ",
"NEM region": "VIC",
"ISP sub-region": "SEV",
},
]
)

"""
New entrant property columns (keys) mapped to the IASR table and columns that contain
property values and the technology for which the values apply. Consumed by
Expand Down
18 changes: 15 additions & 3 deletions src/ispypsa/templater/transmission.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import pandas as pd

from ispypsa.templater.helpers import _build_geo_region_lookup
from ispypsa.templater.mappings import _CANONICAL_TIMESLICES, _SINGLE_REGION_ID
from ispypsa.templater.mappings import (
_CANONICAL_TIMESLICES,
_NON_REZ_GEOGRAPHIES,
_SINGLE_REGION_ID,
)

_HVDC_PATH_IDS = {"NNSW-SQ_Terranora", "WNV-CSA_Murraylink", "TAS-SEV"}

Expand Down Expand Up @@ -605,10 +609,18 @@ def _log_rez_paths_absent_from_limits(
"""Logs REZ paths whose REZ ID has no row in initial_transmission_limits.

These paths get NaN capacity rows from the left-merge, are collapsed by
``_collapse_paths_with_no_limits``, and receive a default downstream.
``_collapse_paths_with_no_limits``, and receive a default downstream. Non-REZ
geo_ids that have been added to 'renewable_energy_zones' (N0 and V0, see
``geography._add_non_rez_geographies_to_rez_table``) are expected to have no
initial limit, so are excluded from this log.
"""
rez_with_limits = set(initial_transmission_limits["REZ ID"])
missing = sorted(set(rez_connection_rows["geo_from"]) - rez_with_limits)
known_non_rezs_with_no_limits = set(_NON_REZ_GEOGRAPHIES["ID"])
missing = sorted(
set(rez_connection_rows["geo_from"])
- rez_with_limits
- known_non_rezs_with_no_limits
)
if missing:
logging.warning(
f"REZs absent from initial_transmission_limits "
Expand Down
54 changes: 21 additions & 33 deletions tests/test_cli/test_create_ispypsa_inputs_new_table_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,14 @@ def test_create_ispypsa_inputs_task_new_format(
_NUM_SUBREGIONS_75 = 15
_NUM_REGIONS_75 = 5
_NUM_REZS_75 = 47
_NUM_NON_REZS_75 = 2
_NUM_FLOW_PATHS_75 = 18

# REZs whose row in `initial_transmission_limits` has all-NaN limit columns.
# Each collapses to a single placeholder row in network_transmission_path_limits
# instead of the usual `NUM_TIMESLICES × NUM_DIRECTIONS` rows.
_NUM_REZS_WITHOUT_LIMITS_75 = 15
# All non-REZ geographies (N0/V0) have no limits so counts are added here.
_NUM_REZS_WITHOUT_LIMITS_75 = 15 + _NUM_NON_REZS_75

# Flow paths in the 7.5 input whose augmentation key has no exact match in
# `flow_path_transfer_capability` and that therefore get a synthetic
Expand Down Expand Up @@ -276,10 +278,13 @@ def test_create_ispypsa_inputs_task_new_format(

_PATHS_PER_GRANULARITY_75 = {
"sub_regions": (
_NUM_FLOW_PATHS_75 + _NUM_REZS_75 + _NUM_PARALLEL_PATHS_INJECTED_75_SUB_REGIONS
_NUM_FLOW_PATHS_75
+ _NUM_REZS_75
+ _NUM_NON_REZS_75
+ _NUM_PARALLEL_PATHS_INJECTED_75_SUB_REGIONS
),
"nem_regions": _NUM_INTER_REGIONAL_FLOW_PATHS_75 + _NUM_REZS_75,
"single_region": _NUM_REZS_75,
"nem_regions": _NUM_INTER_REGIONAL_FLOW_PATHS_75 + _NUM_REZS_75 + _NUM_NON_REZS_75,
"single_region": _NUM_REZS_75 + _NUM_NON_REZS_75,
}

# Drift-detection only — per-option year coverage varies.
Expand Down Expand Up @@ -307,10 +312,6 @@ def test_create_ispypsa_inputs_task_new_format(
"single_region": 135,
}

# "Non-REZ" placeholder REZ IDs used by generation technologies with no specific
# REZ location (state-wide VRE/DER). See Open-ISP/ISPyPSA#133
_NON_REZ_PLACEHOLDER_GEO_IDS = {"N0", "V0"}

# Drift-detection only — no collapse step for existing/planned generators (unlike
# new entrant), so this is the same at every granularity.
_EXPECTED_GENERATORS_EXISTING_PLANNED_ROWS_75 = 531
Expand All @@ -319,12 +320,6 @@ def test_create_ispypsa_inputs_task_new_format(
# remains the same at every granularity.
_EXPECTED_STORAGE_EXISTING_PLANNED_ROWS_75 = 111

# REZ sub-zone ids used by some existing/planned generators that don't appear in
# renewable_energy_zones (only their parent REZ does, e.g. "Q8" not "Q8a"/"Q8b"/
# "Q8c"). Same gap class as _NON_REZ_PLACEHOLDER_GEO_IDS (#133), different cause.
# NOTE: placeholder fix for now - will be properly addressed in future PR (next up)
_MISSING_REZ_SUBZONE_GEO_IDS = {"Q8a", "Q8b", "Q8c"}

# Per-reference-year window patterns decoded from the shipped RefYear5000
# calendar and reference_year_sequence (drift-detection;
# granularity-invariant). 14 distinct ids, not 15: TAS Hot Day never
Expand Down Expand Up @@ -382,7 +377,10 @@ def test_create_ispypsa_inputs_new_format(
storage_existing_planned = pd.read_csv(output_dir / "storage_existing_planned.csv")

# network_geography — one row per (sub-)region or REZ; geo_ids are unique.
assert len(geo) == _GEOS_PER_GRANULARITY_75[granularity] + _NUM_REZS_75
assert (
len(geo)
== _GEOS_PER_GRANULARITY_75[granularity] + _NUM_REZS_75 + _NUM_NON_REZS_75
)
assert geo["geo_id"].is_unique

# network_transmission_paths — derived from named structural quantities:
Expand Down Expand Up @@ -423,14 +421,12 @@ def test_create_ispypsa_inputs_new_format(
# generators_new_entrant / storage_new_entrant — row counts drop at coarser
# granularities as sub-region-located technology options merge into one row
# per region (REZ-located rows are unaffected). geo_ids are real network_geography
# entries, except the pre-existing "Non-REZ" placeholder gap (ISPyPSA#133)
# entries.
assert (
len(gens_new_entrant) == _EXPECTED_GENERATORS_NEW_ENTRANT_ROWS_75[granularity]
)
assert gens_new_entrant["name"].is_unique
assert set(gens_new_entrant["geo_id"]) <= (
set(geo["geo_id"]) | _NON_REZ_PLACEHOLDER_GEO_IDS
)
assert set(gens_new_entrant["geo_id"]) <= (set(geo["geo_id"]))

assert (
len(storage_new_entrant) == _EXPECTED_STORAGE_NEW_ENTRANT_ROWS_75[granularity]
Expand All @@ -440,30 +436,22 @@ def test_create_ispypsa_inputs_new_format(

# generators_existing_planned — one row per existing/planned generating unit,
# no collapse step (unlike new entrant) so the row count doesn't vary by
# granularity. geo_ids are real network_geography entries, except the two
# pre-existing gaps noted above ("Non-REZ" placeholders and REZ sub-zones).
# granularity. geo_ids are real network_geography entries.
assert len(gens_existing_planned) == _EXPECTED_GENERATORS_EXISTING_PLANNED_ROWS_75
assert gens_existing_planned["name"].is_unique
assert set(gens_existing_planned["geo_id"]) <= (
set(geo["geo_id"]) | _NON_REZ_PLACEHOLDER_GEO_IDS | _MISSING_REZ_SUBZONE_GEO_IDS
)
assert set(gens_existing_planned["geo_id"]) <= set(geo["geo_id"])

# as above - storage_existing_planned — one row per existing/planned storage unit,
# no collapse step so the row count doesn't vary. geo_ids are real network_geography
# entries, except the two pre-existing gaps noted above ("Non-REZ" placeholders
# and REZ sub-zones).
# entries.
assert len(storage_existing_planned) == _EXPECTED_STORAGE_EXISTING_PLANNED_ROWS_75
assert storage_existing_planned["name"].is_unique
assert set(storage_existing_planned["geo_id"]) <= (
set(geo["geo_id"]) | _NON_REZ_PLACEHOLDER_GEO_IDS | _MISSING_REZ_SUBZONE_GEO_IDS
)
assert set(storage_existing_planned["geo_id"]) <= set(geo["geo_id"])

# costs_connection - every new entrant technology + geo_id + year has a row
# geo_id's are real network_geography entries + _NON_REZ_PLACEHOLDER_GEO_IDS
# geo_id's are real network_geography entries.
assert len(costs_connection) == _EXPECTED_COSTS_CONNECTION_ROWS_75[granularity]
assert set(costs_connection["geo_id"]) <= (
set(geo["geo_id"]) | _NON_REZ_PLACEHOLDER_GEO_IDS
)
assert set(costs_connection["geo_id"]) <= set(geo["geo_id"])

# timeslices — per-reference-year patterns decoded from the shipped
# RefYear5000 calendar, identical at every granularity.
Expand Down
Loading
Loading