diff --git a/src/ispypsa/pypsa_build/links.py b/src/ispypsa/pypsa_build/links.py index 36652bde..e8ec3759 100644 --- a/src/ispypsa/pypsa_build/links.py +++ b/src/ispypsa/pypsa_build/links.py @@ -1,16 +1,362 @@ import pandas as pd import pypsa +_LIMIT_PER_SNAPSHOT_COLUMNS = [ + "name", + "attribute", + "investment_periods", + "snapshots", + "value", +] -def _add_links_to_network(network: pypsa.Network, links: pd.DataFrame) -> None: + +def _add_links_to_network( + network: pypsa.Network, + links: pd.DataFrame, + link_timeslice_limits: pd.DataFrame | None = None, + timeslice_snapshots: pd.DataFrame | None = None, +) -> None: """Adds the Links defined in a pypsa-friendly input table called `"links"` to the `pypsa.Network` object. - Args: - network: The `pypsa.Network` object - links: `pd.DataFrame` with `PyPSA` style `Link` attributes. + On the new-format path the two timeslice tables are required: every + existing (non-extendable) link's per-timeslice limits are expanded into + per-snapshot p_max_pu / p_min_pu series that replace the links table's + placeholder values (see _build_link_pu_overrides). Expansion links keep + the links table's static p_max_pu / p_min_pu. On the old-format path both + tables are omitted and every link's p_max_pu / p_min_pu are the limits. + + FEATURE_FLAG_CLEANUP[use_new_table_format]: once the flag is retired, + make link_timeslice_limits and timeslice_snapshots required and drop the + None handling in _build_link_pu_overrides. + + I/O Example (new-format path): + links (the p_max_pu / p_min_pu here are placeholders): + name bus0 bus1 carrier p_nom p_max_pu p_min_pu p_nom_extendable + CQ-NQ_existing CQ NQ AC 1400 1.0 0.0 False + + link_timeslice_limits: + name attribute timeslice value + CQ-NQ_existing p_max_pu qld_peak_demand 0.857 + CQ-NQ_existing p_max_pu , 1.0 # fallback + CQ-NQ_existing p_min_pu , -0.714 # fallback only + + timeslice_snapshots: + timeslice_id investment_periods snapshots + qld_peak_demand 2025 2025-01-13 12:00 + + network.snapshots: + investment_periods snapshots + 2025 2025-01-13 12:00 + 2025 2025-01-15 12:00 + + returns None; network is modified in place. It gains the Link: + network.links: + name bus0 bus1 p_nom p_nom_extendable + CQ-NQ_existing CQ NQ 1400 False + with per-snapshot limits in place of the placeholders: + network.links_t.p_max_pu: + investment_periods snapshots CQ-NQ_existing + 2025 2025-01-13 12:00 0.857 + 2025 2025-01-15 12:00 1.0 + network.links_t.p_min_pu: + investment_periods snapshots CQ-NQ_existing + 2025 2025-01-13 12:00 -0.714 + 2025 2025-01-15 12:00 -0.714 + + I/O Example (old-format path): + links: + name bus0 bus1 carrier p_nom p_max_pu p_min_pu p_nom_extendable + CQ-NQ_existing CQ NQ AC 1400 1.0 -0.5 False - Returns: None + returns None; network is modified in place. It gains the Link with + the links table's p_max_pu / p_min_pu as static values: + network.links: + name bus0 bus1 p_nom p_max_pu p_min_pu p_nom_extendable + CQ-NQ_existing CQ NQ 1400 1.0 -0.5 False + and no per-snapshot series (network.links_t.p_max_pu / p_min_pu have + no CQ-NQ_existing column). """ + pu_overrides = _build_link_pu_overrides( + links, link_timeslice_limits, timeslice_snapshots, network.snapshots + ) links["class_name"] = "Link" - links.apply(lambda row: network.add(**row.to_dict()), axis=1) + for _, row in links.iterrows(): + network.add(**(row.to_dict() | pu_overrides.get(row["name"], {}))) + + +def _build_link_pu_overrides( + links: pd.DataFrame, + link_timeslice_limits: pd.DataFrame | None, + timeslice_snapshots: pd.DataFrame | None, + snapshots: pd.MultiIndex, +) -> dict[str, dict[str, pd.Series]]: + """Expands each existing link's per-timeslice limits into per-snapshot series. + + The translator emits two kinds of limit row per (link, attribute): rows + with a named timeslice, which apply at the snapshots that timeslice is + active, and a row with timeslice = NaN, which is the fallback for every + snapshot no named timeslice covers. Every existing (non-extendable) link + must end up with a value for both p_max_pu and p_min_pu at every snapshot + from one or the other; a link or snapshot left without one raises rather + than silently keeping the links table's placeholder p_max_pu / p_min_pu. + Expansion links are not checked: their p_max_pu / p_min_pu are static. + + I/O Example: + links (only name and p_nom_extendable are read): + name p_nom_extendable + CQ-NQ_existing False + CQ-NQ_option_1 True # expansion link: not checked + + link_timeslice_limits: + name attribute timeslice value + CQ-NQ_existing p_max_pu qld_peak_demand 0.857 + CQ-NQ_existing p_max_pu , 1.0 # fallback + CQ-NQ_existing p_min_pu , -0.714 # fallback only + + timeslice_snapshots: + timeslice_id investment_periods snapshots + qld_peak_demand 2025 2025-01-13 12:00 + + snapshots: + investment_periods snapshots + 2025 2025-01-13 12:00 + 2025 2025-01-15 12:00 + + returns (each series indexed by snapshots): + {"CQ-NQ_existing": {"p_max_pu": [0.857, 1.0], + "p_min_pu": [-0.714, -0.714]}} + + Without the p_max_pu fallback row, p_max_pu would be undefined at the + second snapshot -> ValueError. Without any p_min_pu row, p_min_pu would + be undefined at every snapshot -> ValueError. + """ + if link_timeslice_limits is None: + return {} + limits_per_snapshot = _expand_limits_to_snapshots( + links, link_timeslice_limits, timeslice_snapshots, snapshots + ) + _raise_if_snapshots_uncovered(limits_per_snapshot) + return _series_by_link_and_attribute(limits_per_snapshot, snapshots) + + +def _expand_limits_to_snapshots( + links: pd.DataFrame, + link_timeslice_limits: pd.DataFrame, + timeslice_snapshots: pd.DataFrame, + snapshots: pd.MultiIndex, +) -> pd.DataFrame: + """One row per (existing link, attribute, snapshot): the named timeslice's + value where one is active there, else the fallback, else NaN. + + I/O Example: + links (only name and p_nom_extendable are read): + name p_nom_extendable + CQ-NQ_existing False + + link_timeslice_limits: + name attribute timeslice value + CQ-NQ_existing p_max_pu qld_peak_demand 0.857 + CQ-NQ_existing p_max_pu , 1.0 + CQ-NQ_existing p_min_pu qld_peak_demand -0.9 # no fallback + + timeslice_snapshots: + timeslice_id investment_periods snapshots + qld_peak_demand 2025 2025-01-13 12:00 + + snapshots: + investment_periods snapshots + 2025 2025-01-13 12:00 + 2025 2025-01-15 12:00 + + returns: + name attribute investment_periods snapshots value + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 + CQ-NQ_existing p_max_pu 2025 2025-01-15 12:00 1.0 # fallback + CQ-NQ_existing p_min_pu 2025 2025-01-13 12:00 -0.9 + CQ-NQ_existing p_min_pu 2025 2025-01-15 12:00 # uncovered + """ + is_fallback = link_timeslice_limits["timeslice"].isna() + named = _place_named_limits_at_snapshots( + link_timeslice_limits[~is_fallback], timeslice_snapshots + ) + fallback = link_timeslice_limits.loc[is_fallback, ["name", "attribute", "value"]] + grid = _create_link_attribute_snapshot_grid(links, snapshots) + grid = grid.merge(named, how="left") + grid = grid.merge(fallback.rename(columns={"value": "fallback"}), how="left") + grid["value"] = grid["value"].fillna(grid["fallback"]) + return grid.loc[:, _LIMIT_PER_SNAPSHOT_COLUMNS] + + +def _create_link_attribute_snapshot_grid( + links: pd.DataFrame, snapshots: pd.MultiIndex +) -> pd.DataFrame: + """Every existing (non-extendable) link, for both p_max_pu and p_min_pu, at + every snapshot. Expansion links are left out: their limits are static. + + I/O Example: + links (only name and p_nom_extendable are read): + name p_nom_extendable + CQ-NQ_existing False + CQ-NQ_option_1 True # left out + + snapshots: + investment_periods snapshots + 2025 2025-01-13 12:00 + 2025 2025-01-15 12:00 + + returns: + name attribute investment_periods snapshots + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 + CQ-NQ_existing p_max_pu 2025 2025-01-15 12:00 + CQ-NQ_existing p_min_pu 2025 2025-01-13 12:00 + CQ-NQ_existing p_min_pu 2025 2025-01-15 12:00 + """ + existing = links.loc[~links["p_nom_extendable"], ["name"]] + attributes = pd.DataFrame({"attribute": ["p_max_pu", "p_min_pu"]}) + snapshot_rows = pd.DataFrame( + snapshots.tolist(), columns=["investment_periods", "snapshots"] + ) + return existing.merge(attributes, how="cross").merge(snapshot_rows, how="cross") + + +def _place_named_limits_at_snapshots( + named: pd.DataFrame, timeslice_snapshots: pd.DataFrame +) -> pd.DataFrame: + """Places each named-timeslice limit at the snapshots its timeslice is active. + + A timeslice with no snapshots contributes no rows (the translator has + already logged it). + + I/O Example: + named: + name attribute timeslice value + CQ-NQ_existing p_max_pu qld_peak_demand 0.857 + + timeslice_snapshots: + timeslice_id investment_periods snapshots + qld_peak_demand 2025 2025-01-13 12:00 + + returns: + name attribute investment_periods snapshots value + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 + """ + active_at = timeslice_snapshots.rename(columns={"timeslice_id": "timeslice"}) + active_at["snapshots"] = pd.to_datetime(active_at["snapshots"]) + placed = named.merge(active_at, on="timeslice") + return placed.loc[:, _LIMIT_PER_SNAPSHOT_COLUMNS] + + +def _raise_if_snapshots_uncovered(limits_per_snapshot: pd.DataFrame) -> None: + """Raises if any (link, attribute) has a snapshot with neither a named-timeslice + nor a fallback value. + + The message separates pairs undefined at every snapshot (typically a link + with no limit rows at all) from pairs undefined at only some snapshots + (named-timeslice rows with no fallback), and samples the uncovered + snapshots of the latter. + + I/O Example: + limits_per_snapshot: + name attribute investment_periods snapshots value + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 + CQ-NQ_existing p_max_pu 2025 2025-01-15 12:00 1.0 + returns None + + limits_per_snapshot: + name attribute investment_periods snapshots value + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 # no rows at all + CQ-NQ_existing p_max_pu 2025 2025-01-15 12:00 + CQ-NQ_existing p_min_pu 2025 2025-01-13 12:00 -0.9 + CQ-NQ_existing p_min_pu 2025 2025-01-15 12:00 # no fallback + raises ValueError: "... Undefined at every snapshot: [('CQ-NQ_existing', + 'p_max_pu')]. Undefined at some snapshots: [('CQ-NQ_existing', 'p_min_pu')], + first uncovered (investment_period, snapshot): (2025, 2025-01-15 12:00:00)" + """ + uncovered = limits_per_snapshot[limits_per_snapshot["value"].isna()] + if uncovered.empty: + return + at_every, at_some = _split_pairs_by_uncovered_extent(limits_per_snapshot, uncovered) + sample = _first_uncovered_snapshots(uncovered, at_some) + raise ValueError( + f"link_timeslice_limits leaves p_max_pu / p_min_pu undefined for existing " + f"links: no fallback (blank-timeslice) row and no named timeslice active " + f"there. Undefined at every snapshot: {at_every}. " + f"Undefined at some snapshots: {at_some}{sample}" + ) + + +def _split_pairs_by_uncovered_extent( + limits_per_snapshot: pd.DataFrame, uncovered: pd.DataFrame +) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """Splits the (link, attribute) pairs with uncovered snapshots into those + uncovered at every snapshot and those uncovered at only some. + + I/O Example: + limits_per_snapshot: A p_max_pu at 2 snapshots (both NaN), + A p_min_pu at 2 snapshots (one NaN) + uncovered: the 3 NaN rows + + returns ([("A", "p_max_pu")], [("A", "p_min_pu")]) + """ + keys = ["name", "attribute"] + n_snapshots = limits_per_snapshot.groupby(keys).size() + n_uncovered = uncovered.groupby(keys).size() + at_every = n_uncovered == n_snapshots.loc[n_uncovered.index] + return sorted(at_every[at_every].index), sorted(at_every[~at_every].index) + + +def _first_uncovered_snapshots( + uncovered: pd.DataFrame, pairs: list[tuple[str, str]] +) -> str: + """An error-message clause sampling the first five uncovered snapshots of the + given (link, attribute) pairs; empty when there are no pairs to sample. + + I/O Example: + uncovered: + name attribute investment_periods snapshots + A p_max_pu 2025 2025-01-13 12:00 + A p_min_pu 2025 2025-01-15 12:00 + pairs: [("A", "p_min_pu")] + returns ", first uncovered (investment_period, snapshot): (2025, 2025-01-15 12:00:00)" + + pairs: [] + returns "" + """ + if not pairs: + return "" + rows = uncovered.set_index(["name", "attribute"]).loc[pairs].head(5) + sample = ", ".join( + f"({period}, {snapshot})" + for period, snapshot in zip(rows["investment_periods"], rows["snapshots"]) + ) + return f", first uncovered (investment_period, snapshot): {sample}" + + +def _series_by_link_and_attribute( + limits_per_snapshot: pd.DataFrame, snapshots: pd.MultiIndex +) -> dict[str, dict[str, pd.Series]]: + """Reshapes the long table into {link: {attribute: series indexed by snapshots}}. + + I/O Example: + limits_per_snapshot: + name attribute investment_periods snapshots value + CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 + CQ-NQ_existing p_max_pu 2025 2025-01-15 12:00 1.0 + CQ-NQ_existing p_min_pu 2025 2025-01-13 12:00 -0.714 + CQ-NQ_existing p_min_pu 2025 2025-01-15 12:00 -0.714 + + snapshots: + investment_periods snapshots + 2025 2025-01-13 12:00 + 2025 2025-01-15 12:00 + + returns (each series indexed by snapshots): + {"CQ-NQ_existing": {"p_max_pu": [0.857, 1.0], + "p_min_pu": [-0.714, -0.714]}} + """ + overrides = {} + for (name, attribute), rows in limits_per_snapshot.groupby(["name", "attribute"]): + series = rows.set_index(["investment_periods", "snapshots"])["value"] + overrides.setdefault(name, {})[attribute] = series.reindex(snapshots) + return overrides diff --git a/src/ispypsa/translator/network.py b/src/ispypsa/translator/network.py index 66233841..454adfdb 100644 --- a/src/ispypsa/translator/network.py +++ b/src/ispypsa/translator/network.py @@ -350,9 +350,7 @@ def _translate_timeslice_limits_to_pu( Named-timeslice rows carry their timeslice; a timeslice = NaN row is the fallback applied to snapshots no named timeslice covers (the coverage contract is Open-ISP/ISPyPSA#123). pypsa_build expands these into per-snapshot - series via the timeslice_snapshots mapping. Zero-p_nom links (new parallel - corridors) are skipped — all their limits are zero and the per-unit form is - undefined. + series via the timeslice_snapshots mapping. I/O Example: limits: @@ -370,21 +368,24 @@ def _translate_timeslice_limits_to_pu( CQ-NQ_existing p_max_pu qld_peak_demand 0.857 # 1200/1400 CQ-NQ_existing p_max_pu qld_winter_reference 1.0 # 1400/1400 CQ-NQ_existing p_min_pu , -0.714 # fallback, -1000/1400 + + A zero-p_nom link with capacity 0 in a row -> value 0.0 for that row. """ rows = limits.merge( existing_links.loc[:, ["isp_name", "name", "p_nom"]], left_on="path_id", right_on="isp_name", ) - # Zero-p_nom links (new parallel corridors) have no per-unit form, so they - # contribute no timeslice rows. - rows = rows[rows["p_nom"] > 0] rows["attribute"] = rows["direction"].map( {"forward": "p_max_pu", "reverse": "p_min_pu"} ) # Reverse flow is negative, so reverse limits become negative p_min_pu values. sign = rows["direction"].map({"forward": 1.0, "reverse": -1.0}) - rows["value"] = sign * rows["capacity"] / rows["p_nom"] + # A zero-p_nom link (new parallel corridor) has only zero limits; 0/0 is + # undefined, so its per-unit value is defined as 0. + rows["value"] = np.where( + rows["p_nom"] > 0, sign * rows["capacity"] / rows["p_nom"], 0.0 + ) return rows.loc[:, _LINK_TIMESLICE_LIMIT_COLUMNS].reset_index(drop=True) diff --git a/tests/test_model/test_add_links_with_timeslice_limits.py b/tests/test_model/test_add_links_with_timeslice_limits.py new file mode 100644 index 00000000..8de262cf --- /dev/null +++ b/tests/test_model/test_add_links_with_timeslice_limits.py @@ -0,0 +1,378 @@ +import pandas as pd +import pypsa +import pytest + +from ispypsa.pypsa_build.links import ( + _add_links_to_network, + _expand_limits_to_snapshots, +) + + +def _network() -> pypsa.Network: + snapshots = pd.date_range("2025-01-01", periods=4, freq="h") + index = pd.MultiIndex.from_arrays([[2025] * 4, list(snapshots)]) + network = pypsa.Network(snapshots=index, investment_periods=[2025]) + network.add("Bus", "bus1") + network.add("Bus", "bus2") + return network + + +def _links(csv_str_to_df) -> pd.DataFrame: + # p_max_pu / p_min_pu are the translator's placeholders, never read on the + # new-format path; the real limits come from link_timeslice_limits. + return csv_str_to_df(""" + name, bus0, bus1, carrier, p_nom, p_max_pu, p_min_pu, p_nom_extendable + CQ-NQ_existing, bus1, bus2, AC, 1400, 1.0, 0.0, False + """) + + +def _link_pu_limits(network: pypsa.Network, name: str) -> pd.DataFrame: + """The per-snapshot p_max_pu / p_min_pu of one link, one row per snapshot.""" + limits = pd.DataFrame( + { + "p_max_pu": network.links_t.p_max_pu[name], + "p_min_pu": network.links_t.p_min_pu[name], + } + ) + limits.index = limits.index.set_names(["investment_periods", "snapshots"]) + return limits.reset_index() + + +def test_named_timeslices_overlay_the_fallback(csv_str_to_df): + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + CQ-NQ_existing, p_min_pu, qld_peak_demand, -0.9 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + qld_peak_demand, 2025, 2025-01-01 02:00:00 + """) + + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + expected = csv_str_to_df(""" + investment_periods, snapshots, p_max_pu, p_min_pu + 2025, 2025-01-01 00:00:00, 1.0, -0.714 + 2025, 2025-01-01 01:00:00, 0.857, -0.9 + 2025, 2025-01-01 02:00:00, 0.857, -0.9 + 2025, 2025-01-01 03:00:00, 1.0, -0.714 + """) + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(_link_pu_limits(network, "CQ-NQ_existing"), expected) + + +def test_fallback_only_attribute_gets_the_fallback_at_every_snapshot(csv_str_to_df): + # The static p_min_pu placeholder (0.0) must not survive at uncovered + # snapshots — that would silently disable reverse flow. + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + expected = csv_str_to_df(""" + investment_periods, snapshots, p_max_pu, p_min_pu + 2025, 2025-01-01 00:00:00, 1.0, -0.714 + 2025, 2025-01-01 01:00:00, 0.857, -0.714 + 2025, 2025-01-01 02:00:00, 1.0, -0.714 + 2025, 2025-01-01 03:00:00, 1.0, -0.714 + """) + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(_link_pu_limits(network, "CQ-NQ_existing"), expected) + + +@pytest.mark.xfail( + reason="Open-ISP/ISPyPSA#138: an all-blank timeslice column is read from CSV as " + "float64 and cannot be merged onto the object-typed (empty) timeslice_snapshots", + raises=ValueError, + strict=True, +) +def test_fallback_only_limits_with_no_timeslices_apply_at_every_snapshot(csv_str_to_df): + # The translator emits this shape for zero-capacity corridors and all-default + # paths in a run with no timeslices configured. + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, , 0.0 + CQ-NQ_existing, p_min_pu, , 0.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + """) + + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + expected = csv_str_to_df(""" + investment_periods, snapshots, p_max_pu, p_min_pu + 2025, 2025-01-01 00:00:00, 0.0, 0.0 + 2025, 2025-01-01 01:00:00, 0.0, 0.0 + 2025, 2025-01-01 02:00:00, 0.0, 0.0 + 2025, 2025-01-01 03:00:00, 0.0, 0.0 + """) + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(_link_pu_limits(network, "CQ-NQ_existing"), expected) + + +def test_named_timeslices_that_tile_the_snapshots_need_no_fallback(csv_str_to_df): + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, qld_winter_reference, 1.0 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_winter_reference, 2025, 2025-01-01 00:00:00 + qld_peak_demand, 2025, 2025-01-01 01:00:00 + qld_peak_demand, 2025, 2025-01-01 02:00:00 + qld_winter_reference, 2025, 2025-01-01 03:00:00 + """) + + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + expected = csv_str_to_df(""" + investment_periods, snapshots, p_max_pu, p_min_pu + 2025, 2025-01-01 00:00:00, 1.0, -0.714 + 2025, 2025-01-01 01:00:00, 0.857, -0.714 + 2025, 2025-01-01 02:00:00, 0.857, -0.714 + 2025, 2025-01-01 03:00:00, 1.0, -0.714 + """) + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(_link_pu_limits(network, "CQ-NQ_existing"), expected) + + +def test_named_timeslice_with_no_snapshots_leaves_the_fallback(csv_str_to_df): + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + """) + + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + expected = csv_str_to_df(""" + investment_periods, snapshots, p_max_pu, p_min_pu + 2025, 2025-01-01 00:00:00, 1.0, -0.714 + 2025, 2025-01-01 01:00:00, 1.0, -0.714 + 2025, 2025-01-01 02:00:00, 1.0, -0.714 + 2025, 2025-01-01 03:00:00, 1.0, -0.714 + """) + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(_link_pu_limits(network, "CQ-NQ_existing"), expected) + + +def test_snapshot_covered_by_neither_named_timeslice_nor_fallback_raises(csv_str_to_df): + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + with pytest.raises(ValueError) as excinfo: + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + assert str(excinfo.value) == ( + "link_timeslice_limits leaves p_max_pu / p_min_pu undefined for existing " + "links: no fallback (blank-timeslice) row and no named timeslice active " + "there. Undefined at every snapshot: []. " + "Undefined at some snapshots: [('CQ-NQ_existing', 'p_max_pu')], " + "first uncovered (investment_period, snapshot): (2025, 2025-01-01 00:00:00), " + "(2025, 2025-01-01 02:00:00), (2025, 2025-01-01 03:00:00)" + ) + + +def test_existing_link_with_no_timeslice_limits_raises(csv_str_to_df): + # The links table's p_max_pu / p_min_pu are placeholders on the new-format + # path, so an existing link the limits table never mentions must not + # silently keep them. + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + """) + + with pytest.raises(ValueError) as excinfo: + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + assert str(excinfo.value) == ( + "link_timeslice_limits leaves p_max_pu / p_min_pu undefined for existing " + "links: no fallback (blank-timeslice) row and no named timeslice active " + "there. Undefined at every snapshot: [('CQ-NQ_existing', 'p_max_pu'), " + "('CQ-NQ_existing', 'p_min_pu')]. Undefined at some snapshots: []" + ) + + +def test_existing_link_missing_one_attribute_raises(csv_str_to_df): + # p_max_pu is fully covered but there is no p_min_pu row at all: the + # placeholder p_min_pu = 0.0 would silently disable reverse flow. + network = _network() + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + with pytest.raises(ValueError) as excinfo: + _add_links_to_network( + network, _links(csv_str_to_df), link_timeslice_limits, timeslice_snapshots + ) + + assert str(excinfo.value) == ( + "link_timeslice_limits leaves p_max_pu / p_min_pu undefined for existing " + "links: no fallback (blank-timeslice) row and no named timeslice active " + "there. Undefined at every snapshot: [('CQ-NQ_existing', 'p_min_pu')]. " + "Undefined at some snapshots: []" + ) + + +def test_expansion_links_keep_their_static_values(csv_str_to_df): + # Expansion links (p_nom_extendable) carry real static p_max_pu / p_min_pu + # and are not in link_timeslice_limits, so they are neither overridden nor + # required to be covered. + network = _network() + links = csv_str_to_df(""" + name, bus0, bus1, carrier, p_nom, p_max_pu, p_min_pu, p_nom_extendable + CQ-NQ_existing, bus1, bus2, AC, 1400, 1.0, 0.0, False + CQ-NQ_option_1, bus1, bus2, AC, 0, 0.9, -0.6, True + """) + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + CQ-NQ_existing, p_min_pu, , -0.714 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_links_to_network(network, links, link_timeslice_limits, timeslice_snapshots) + + expected = csv_str_to_df(""" + Link, p_max_pu, p_min_pu + CQ-NQ_existing, 1.0, 0.0 + CQ-NQ_option_1, 0.9, -0.6 + """).set_index("Link") + # The existing link's static values are the untouched placeholders; its + # real limits live in links_t (covered by the tests above). + pd.testing.assert_frame_equal( + network.links.loc[:, ["p_max_pu", "p_min_pu"]], expected, check_names=False + ) + assert list(network.links_t.p_max_pu.columns) == ["CQ-NQ_existing"] + assert list(network.links_t.p_min_pu.columns) == ["CQ-NQ_existing"] + + +def test_old_format_call_without_limit_tables(csv_str_to_df): + # With no limit tables the links table's p_max_pu / p_min_pu are the real + # limits and stay static; nothing is checked or overridden. + network = _network() + links = csv_str_to_df(""" + name, bus0, bus1, carrier, p_nom, p_max_pu, p_min_pu, p_nom_extendable + CQ-NQ_existing, bus1, bus2, AC, 1400, 1.0, -0.5, False + """) + + _add_links_to_network(network, links) + + expected = csv_str_to_df(""" + Link, p_nom, p_max_pu, p_min_pu + CQ-NQ_existing, 1400, 1.0, -0.5 + """).set_index("Link") + pd.testing.assert_frame_equal( + network.links.loc[:, ["p_nom", "p_max_pu", "p_min_pu"]], + expected, + check_names=False, + check_dtype=False, + ) + assert list(network.links_t.p_max_pu.columns) == [] + assert list(network.links_t.p_min_pu.columns) == [] + + +def test_expand_limits_to_snapshots(csv_str_to_df): + link_timeslice_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CQ-NQ_existing, p_max_pu, qld_peak_demand, 0.857 + CQ-NQ_existing, p_max_pu, , 1.0 + CQ-NQ_existing, p_min_pu, qld_peak_demand, -0.9 + NQ-CQ_other, p_max_pu, no_snapshots, 0.5 + NQ-CQ_other, p_max_pu, , 0.8 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice_id, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + snapshots = pd.MultiIndex.from_arrays( + [[2025, 2025], pd.to_datetime(["2025-01-01 00:00", "2025-01-01 01:00"])] + ) + links = csv_str_to_df(""" + name, p_nom_extendable + CQ-NQ_existing, False + NQ-CQ_other, False + CQ-NQ_option_1, True + """) + + result = _expand_limits_to_snapshots( + links, link_timeslice_limits, timeslice_snapshots, snapshots + ) + + expected = csv_str_to_df(""" + name, attribute, investment_periods, snapshots, value + CQ-NQ_existing, p_max_pu, 2025, 2025-01-01 00:00:00, 1.0 + CQ-NQ_existing, p_max_pu, 2025, 2025-01-01 01:00:00, 0.857 + CQ-NQ_existing, p_min_pu, 2025, 2025-01-01 00:00:00, + CQ-NQ_existing, p_min_pu, 2025, 2025-01-01 01:00:00, -0.9 + NQ-CQ_other, p_max_pu, 2025, 2025-01-01 00:00:00, 0.8 + NQ-CQ_other, p_max_pu, 2025, 2025-01-01 01:00:00, 0.8 + NQ-CQ_other, p_min_pu, 2025, 2025-01-01 00:00:00, + NQ-CQ_other, p_min_pu, 2025, 2025-01-01 01:00:00, + """) + # Rows: named value where active, fallback elsewhere, NaN where neither + # (CQ-NQ_existing p_min_pu at 00:00); a named timeslice with no snapshots + # (no_snapshots) contributes nothing so NQ-CQ_other takes its fallback; + # NQ-CQ_other has no p_min_pu rows at all so that attribute is all NaN; + # the expansion link CQ-NQ_option_1 is not in the grid. + expected["snapshots"] = pd.to_datetime(expected["snapshots"]) + pd.testing.assert_frame_equal(result, expected) diff --git a/tests/test_translator/test_network.py b/tests/test_translator/test_network.py index 90652be7..3c3e80b0 100644 --- a/tests/test_translator/test_network.py +++ b/tests/test_translator/test_network.py @@ -490,17 +490,66 @@ def test_translate_network_to_links_rezs_attached_to_parent_node( def test_translate_network_to_links_zero_capacity_parallel_path( csv_str_to_df, sample_model_config ): - """A new parallel corridor has zero existing capacity, given as a - timeslice = NaN fallback of 0 in both directions. It becomes an inert - existing link at p_nom 0 (its buildable capacity is modelled by expansion - links), and the zero-p_nom link is skipped when translating per-timeslice - limits, so it yields no link_timeslice_limits rows and no 0/0 division.""" + """A new parallel corridor has zero existing capacity, which the templater + (_new_parallel_path_rows) gives as explicit 0 rows for each direction and + each region-prefixed canonical timeslice, with no fallback row. It becomes + an inert existing link at p_nom 0 (its buildable capacity is modelled by + expansion links) whose per-timeslice limits are per-unit 0 rather than an + undefined 0/0, so pypsa_build treats it like any other link.""" + ispypsa_tables = _network_tables(csv_str_to_df) + ispypsa_tables["network_transmission_paths"] = csv_str_to_df(""" + path_id, geo_from, geo_to, carrier + CNSW-SNW, CNSW, SNW, AC + """) + # The templater's shape: 2 directions x 3 named timeslices, all 0, no fallback. + ispypsa_tables["network_transmission_path_limits"] = csv_str_to_df(""" + 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 + """) + ispypsa_tables["network_expansion_options"] = csv_str_to_df(""" + expansion_id, expansion_type, allowed_expansion, expansion_option + """) + + links, link_timeslice_limits = _translate_network_to_links( + ispypsa_tables, sample_model_config + ) + + expected_links = csv_str_to_df(""" + isp_name, name, carrier, bus0, bus1, p_nom, p_min_pu, p_max_pu, build_year, lifetime, capital_cost, p_nom_extendable, isp_type + CNSW-SNW, CNSW-SNW_existing, AC, CNSW, SNW, 0, 0.0, 1.0, 2025, inf, , False, flow_path + """) + pd.testing.assert_frame_equal(links, expected_links, check_dtype=False) + + expected_limits = csv_str_to_df(""" + name, attribute, timeslice, value + CNSW-SNW_existing, p_max_pu, nsw_peak_demand, 0.0 + CNSW-SNW_existing, p_max_pu, nsw_summer_typical, 0.0 + CNSW-SNW_existing, p_max_pu, nsw_winter_reference, 0.0 + CNSW-SNW_existing, p_min_pu, nsw_peak_demand, 0.0 + CNSW-SNW_existing, p_min_pu, nsw_summer_typical, 0.0 + CNSW-SNW_existing, p_min_pu, nsw_winter_reference, 0.0 + """) + pd.testing.assert_frame_equal( + link_timeslice_limits, expected_limits, check_dtype=False + ) + + +def test_translate_network_to_links_zero_capacity_fallback_row( + csv_str_to_df, sample_model_config +): + """A zero-capacity corridor given as a timeslice = NaN fallback of 0 in + both directions (a hand-authored alternative to the templater's named + rows) also becomes an inert p_nom 0 link with per-unit 0 fallbacks.""" ispypsa_tables = _network_tables(csv_str_to_df) ispypsa_tables["network_transmission_paths"] = csv_str_to_df(""" path_id, geo_from, geo_to, carrier CNSW-SNW, CNSW, SNW, AC """) - # Zero existing capacity as a timeslice = NaN fallback (covers the year). ispypsa_tables["network_transmission_path_limits"] = csv_str_to_df(""" path_id, direction, timeslice, capacity CNSW-SNW, forward, , 0 @@ -521,7 +570,9 @@ def test_translate_network_to_links_zero_capacity_parallel_path( pd.testing.assert_frame_equal(links, expected_links, check_dtype=False) expected_limits = csv_str_to_df(""" - name, attribute, timeslice, value + name, attribute, timeslice, value + CNSW-SNW_existing, p_max_pu, , 0.0 + CNSW-SNW_existing, p_min_pu, , 0.0 """) pd.testing.assert_frame_equal( link_timeslice_limits, expected_limits, check_dtype=False