diff --git a/CLAUDE.md b/CLAUDE.md index 692ace51..cb478ab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,11 @@ def template_network_transmission_paths(iasr_tables, scenario): - **Prefer explicit data over clever detection.** If the set of special cases is small and stable, declare them as data rather than building logic to infer them from surrounding context. +- **Say "investment period", never bare "period".** On its own, "period" reads as a + snapshot or time window. In prose (docstrings, comments, schema descriptions, I/O + Example notes) write "investment period", or name the `investment_period` column + ("a row with a blank investment_period", not "a blank-period row"). Bare `period` is + fine only where it is a literal name, such as PyPSA's snapshot index level. ### Control flow diff --git a/src/ispypsa/pypsa_build/custom_constraints.py b/src/ispypsa/pypsa_build/custom_constraints.py index bb345d26..7795a61f 100644 --- a/src/ispypsa/pypsa_build/custom_constraints.py +++ b/src/ispypsa/pypsa_build/custom_constraints.py @@ -115,3 +115,582 @@ def _add_custom_constraints( raise ValueError( f"{row['constraint_type']} is not a valid constraint type." ) + + +# The linopy model variables each supported (component, attribute) LHS term +# sums, with the sign each enters at. A Storage "p" term is net dispatch: +# discharging counts positive and charging negative, matching PLEXOS's paired +# battery Generation (+c) and Load (-c) Coefficients. Load "p_set" terms aren't +# listed because they are demand data, not variables — see _sum_load_terms. +_TERM_TO_MODEL_VARIABLES = pd.DataFrame( + [ + ("Link", "p", "Link-p", 1.0), + ("Link", "p_nom", "Link-p_nom", 1.0), + ("Generator", "p", "Generator-p", 1.0), + ("Generator", "p_nom", "Generator-p_nom", 1.0), + ("Storage", "p", "StorageUnit-p_dispatch", 1.0), + ("Storage", "p", "StorageUnit-p_store", -1.0), + ], + columns=["component", "attribute", "model_variable", "sign"], +) + +_LOAD_COMPONENT = "Load" +_LOAD_ATTRIBUTE = "p_set" + + +def _add_custom_constraints_with_temporal_scope( + network: pypsa.Network, + custom_constraints_rhs: pd.DataFrame, + custom_constraints_lhs: pd.DataFrame, + timeslice_snapshots: pd.DataFrame, +) -> None: + """Adds the new-format custom constraints to the network's linopy model. + + A linopy constraint is created for each unique constraint_name, + investment_period and timeslice combination in custom_constraints_rhs, and + applied to the snapshots corresponding to its investment_period and + timeslice: + + - When the timeslice is named, the constraint applies at that timeslice's + snapshots in its investment_period, as listed in timeslice_snapshots. + - When the timeslice is blank, it acts as a fallback: the constraint + applies at the snapshots in its investment_period that none of the same + constraint's named timeslices cover. + - A blank investment_period means every investment period, and only + occurs on fallback rows (custom_constraints_rhs schema). The expansion-limit + constraints are the case in practice: blank in both, with a p_nom-only + LHS that has no time dimension. + + A linopy constraint's LHS terms are the custom_constraints_lhs rows with + its constraint_name and the same investment_period, blank matching blank. + The translator resolves every dated input to explicit investment periods, + so a blank investment_period only occurs on constraints that apply + regardless of investment period (the expansion limits). Load terms are demand data rather than variables: + coefficient x p_set becomes a per-snapshot constant, which linopy moves to + the right-hand side. + + Implementation note (linopy): flow and storage-dispatch variables take a + value at every snapshot, so a constraint containing one is enforced + separately at each snapshot it applies at (SWQLD1_2025_qld_peak_demand at + 01:00 and again at 02:00). Capacity (p_nom) variables take a single value, + so a constraint made only of them, with no load terms, is enforced once. + + A linopy constraint whose investment_period and timeslice select no + snapshots isn't created. It's expected under snapshot aggregation, and the + translator warns about each timeslice with no snapshots in a constraint's + investment period + (ispypsa.translator.timeslices._log_referenced_timeslices_without_snapshots). + + Unsupported (component, attribute) terms raise, as does a constraint with + no LHS terms on model variables, since either would silently weaken or + drop the constraint. + + The three input tables' contracts are declared in + src/ispypsa/validation/schemas/pypsa_friendly_tables + (custom_constraints_rhs.yaml, custom_constraints_lhs.yaml and + timeslice_snapshots.yaml). + + I/O Example: + custom_constraints_rhs: + constraint_name investment_period timeslice rhs constraint_type + SWQLD1 2025 qld_peak_demand 3000 <= + SWQLD1 2025 , 3500 <= # fallback: off-peak + CQ-NQ_expansion_limit , , 1000 <= + + custom_constraints_lhs: + constraint_name investment_period variable_name component attribute coefficient + SWQLD1 2025 NSW-QLD_existing Link p 0.84 + SWQLD1 2025 load_SQ Load p_set -0.33 + CQ-NQ_expansion_limit , CQ-NQ_exp_2025 Link p_nom 1.0 + + timeslice_snapshots: + timeslice investment_periods snapshots + qld_peak_demand 2025 2025-01-01 01:00 + qld_peak_demand 2025 2025-01-01 02:00 + + network.snapshots: + period timestep + 2025 2025-01-01 00:00 # no timeslice + 2025 2025-01-01 01:00 # qld_peak_demand + 2025 2025-01-01 02:00 # qld_peak_demand + 2025 2025-01-01 03:00 # no timeslice + + adds constraints (one row per snapshot t in scope, where time-indexed): + SWQLD1_2025_qld_peak_demand, at 01:00 and 02:00: + 0.84 Link-p[t, NSW-QLD_existing] <= 3000 + 0.33 p_set[t, load_SQ] + SWQLD1_2025 (fallback), at 00:00 and 03:00: + 0.84 Link-p[t, NSW-QLD_existing] <= 3500 + 0.33 p_set[t, load_SQ] + CQ-NQ_expansion_limit, no time dimension: + Link-p_nom[CQ-NQ_exp_2025] <= 1000 + """ + _raise_on_unsupported_terms(custom_constraints_lhs) + for rhs_row in custom_constraints_rhs.itertuples(): + scope = _linopy_constraint_snapshots( + rhs_row, custom_constraints_rhs, timeslice_snapshots, network.snapshots + ) + if scope.empty: + continue + name = _linopy_constraint_name(rhs_row) + terms = _select_lhs_terms(custom_constraints_lhs, rhs_row) + expression = _build_lhs_expression(network, terms, scope, name) + _add_linopy_constraint(network.model, expression, rhs_row, name) + + +def _raise_on_unsupported_terms(custom_constraints_lhs: pd.DataFrame) -> None: + """Raises when an LHS term's (component, attribute) has no model mapping. + + I/O Example: + custom_constraints_lhs: + constraint_name variable_name component attribute + SWQLD1 NSW-QLD Link p + SWQLD1 SQ Bus p # unsupported + + raises ValueError: "Custom constraint LHS terms with unsupported + (component, attribute): [('Bus', 'p')]" + """ + supported = set( + zip( + _TERM_TO_MODEL_VARIABLES["component"], _TERM_TO_MODEL_VARIABLES["attribute"] + ) + ) | {(_LOAD_COMPONENT, _LOAD_ATTRIBUTE)} + lhs = custom_constraints_lhs + unsupported = sorted(set(zip(lhs["component"], lhs["attribute"])) - supported) + if unsupported: + raise ValueError( + f"Custom constraint LHS terms with unsupported " + f"(component, attribute): {unsupported}" + ) + + +def _linopy_constraint_snapshots( + rhs_row: tuple, + custom_constraints_rhs: pd.DataFrame, + timeslice_snapshots: pd.DataFrame, + snapshots: pd.MultiIndex, +) -> pd.MultiIndex: + """The snapshots a linopy constraint applies at: its timeslice's snapshots + in its investment_period when the timeslice is named, otherwise its + fallback snapshots. + + rhs_row is one row of custom_constraints_rhs from itertuples(), so its + columns are attributes (rhs_row.timeslice). + + I/O Example: + custom_constraints_rhs: + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand + SWQLD1 2025 , # fallback + + timeslice_snapshots: + timeslice investment_periods snapshots + qld_peak_demand 2025 2025-01-01 01:00 + + snapshots: + period timestep + 2025 2025-01-01 00:00 + 2025 2025-01-01 01:00 + + rhs_row: + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand + + returns: + investment_periods snapshots + 2025 2025-01-01 01:00 + + rhs_row: + constraint_name investment_period timeslice + SWQLD1 2025 , # fallback + + returns: + period timestep + 2025 2025-01-01 00:00 + """ + if pd.isna(rhs_row.timeslice): + return _fallback_snapshots( + rhs_row, custom_constraints_rhs, timeslice_snapshots, snapshots + ) + return _timeslice_snapshots_in_period( + timeslice_snapshots, [rhs_row.timeslice], rhs_row.investment_period + ) + + +def _fallback_snapshots( + rhs_row: tuple, + custom_constraints_rhs: pd.DataFrame, + timeslice_snapshots: pd.DataFrame, + snapshots: pd.MultiIndex, +) -> pd.MultiIndex: + """The snapshots in a fallback constraint's investment_period (every + investment period when blank) that none of its constraint_name's named + timeslices in that investment period cover. + + I/O Example: + rhs_row: + constraint_name investment_period timeslice + SWQLD1 2025 , + + custom_constraints_rhs: + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand + SWQLD1 2025 , # rhs_row itself + + timeslice_snapshots: + timeslice investment_periods snapshots + qld_peak_demand 2025 2025-01-01 01:00 + + snapshots: + period timestep + 2025 2025-01-01 00:00 + 2025 2025-01-01 01:00 # covered by SWQLD1's qld_peak_demand + 2030 2030-01-01 00:00 # other investment period + + returns: + period timestep + 2025 2025-01-01 00:00 + """ + in_period = _snapshots_in_period(snapshots, rhs_row.investment_period) + named = _timeslices_named_in_period(custom_constraints_rhs, rhs_row) + covered = _timeslice_snapshots_in_period( + timeslice_snapshots, named, rhs_row.investment_period + ) + return in_period[~in_period.isin(covered)] + + +def _timeslices_named_in_period( + custom_constraints_rhs: pd.DataFrame, rhs_row: tuple +) -> list[str]: + """The timeslices named on rhs_row's constraint_name in its + investment_period. None for a row with a blank investment_period: only + fallback rows leave it blank (custom_constraints_rhs schema), and a blank + investment_period matches nothing here. + + I/O Example: + custom_constraints_rhs: + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand + SWQLD1 2025 , # the fallback itself + SWQLD1 2030 qld_summer_typical # other investment period + SWQLD2 2025 qld_summer_typical # other constraint + + rhs_row: + constraint_name investment_period timeslice + SWQLD1 2025 , + + returns: ["qld_peak_demand"] + """ + rhs = custom_constraints_rhs + same_constraint = rhs["constraint_name"] == rhs_row.constraint_name + same_period = rhs["investment_period"] == rhs_row.investment_period + return list(rhs.loc[same_constraint & same_period, "timeslice"].dropna()) + + +def _timeslice_snapshots_in_period( + timeslice_snapshots: pd.DataFrame, + timeslices: list[str], + investment_period: float, +) -> pd.MultiIndex: + """The snapshots in one investment_period at which any of the given + timeslices is active, as an (investment_periods, snapshots) index. A blank + (NaN) investment_period matches no snapshots. + + I/O Example: + timeslice_snapshots: + timeslice investment_periods snapshots + qld_peak_demand 2025 2025-01-01 01:00 + qld_summer_typical 2025 2025-01-01 02:00 + qld_peak_demand 2030 2030-01-01 01:00 # other investment period + nsw_peak_demand 2025 2025-01-01 01:00 # not listed + + timeslices: ["qld_peak_demand", "qld_summer_typical"] + investment_period: 2025 + + returns: + investment_periods snapshots + 2025 2025-01-01 01:00 + 2025 2025-01-01 02:00 + """ + in_scope = timeslice_snapshots["timeslice"].isin(timeslices) & ( + timeslice_snapshots["investment_periods"] == investment_period + ) + selected = timeslice_snapshots.loc[in_scope] + return pd.MultiIndex.from_arrays( + [selected["investment_periods"], pd.to_datetime(selected["snapshots"])] + ) + + +def _snapshots_in_period( + snapshots: pd.MultiIndex, investment_period: float +) -> pd.MultiIndex: + """The snapshots in one investment period, or all of them when it's blank + (NaN). + + I/O Example: + snapshots: + period timestep + 2025 2025-01-01 00:00 + 2030 2030-01-01 00:00 + + investment_period: 2025 + + returns: + period timestep + 2025 2025-01-01 00:00 + + investment_period: NaN + + returns: + period timestep + 2025 2025-01-01 00:00 + 2030 2030-01-01 00:00 + """ + if pd.isna(investment_period): + return snapshots + return snapshots[snapshots.get_level_values("period") == investment_period] + + +def _select_lhs_terms( + custom_constraints_lhs: pd.DataFrame, rhs_row: tuple +) -> pd.DataFrame: + """The LHS terms of a linopy constraint: those with its constraint_name and + the same investment_period, a blank investment_period matching only a + blank one. + + I/O Example: + custom_constraints_lhs: + constraint_name investment_period variable_name + SWQLD1 2025 KINGASF1 + SWQLD1 2030 KINGASF1 # other investment period: dropped + SWQLD2 2025 KINGASF1 # other constraint: dropped + CQ-NQ_expansion_limit , CQ-NQ_exp_2025 + + rhs_row: + constraint_name investment_period + SWQLD1 2025 + + returns: + constraint_name investment_period variable_name + SWQLD1 2025 KINGASF1 + + rhs_row: + constraint_name investment_period + CQ-NQ_expansion_limit , + + returns: + constraint_name investment_period variable_name + CQ-NQ_expansion_limit , CQ-NQ_exp_2025 + """ + lhs = custom_constraints_lhs + same_constraint = lhs["constraint_name"] == rhs_row.constraint_name + same_period = (lhs["investment_period"] == rhs_row.investment_period) | ( + lhs["investment_period"].isna() & pd.isna(rhs_row.investment_period) + ) + return lhs[same_constraint & same_period] + + +def _build_lhs_expression( + network: pypsa.Network, terms: pd.DataFrame, scope: pd.MultiIndex, name: str +) -> linopy.LinearExpression: + """Builds sum(coefficient x variable) over a linopy constraint's variable + terms, plus its load terms as a constant (which linopy moves to the RHS). + + I/O Example: + terms: + variable_name component attribute coefficient + NSW-QLD_existing Link p 0.84 + load_SQ Load p_set -0.33 + + scope: + investment_periods snapshots + 2025 2025-01-01 01:00 + + name: "SWQLD1_2025_qld_peak_demand" # only used in the error message + + returns, at each snapshot t in scope: + 0.84 Link-p[t, NSW-QLD_existing] - 0.33 p_set[t, load_SQ] + """ + is_load = terms["component"] == _LOAD_COMPONENT + variable_terms = _expand_terms_to_model_variables(terms[~is_load]) + _raise_if_no_variable_terms(variable_terms, name) + expression = _sum_variable_terms(network.model, variable_terms, scope) + return expression + _sum_load_terms(network, terms[is_load], scope) + + +def _expand_terms_to_model_variables(terms: pd.DataFrame) -> pd.DataFrame: + """Maps each variable term to the model variable(s) it sums, folding each + variable's sign into the coefficient. + + Every term's (component, attribute) must be in _TERM_TO_MODEL_VARIABLES, + so load terms are split off before this is called. The orchestrator + guarantees the rest with _raise_on_unsupported_terms; an unmatched term + would be dropped by the merge. + + I/O Example: + terms: + variable_name component attribute coefficient + Q8 Battery - 2h Storage p 0.43 + + returns: + variable_name model_variable coefficient + Q8 Battery - 2h StorageUnit-p_dispatch 0.43 + Q8 Battery - 2h StorageUnit-p_store -0.43 + """ + expanded = terms.merge(_TERM_TO_MODEL_VARIABLES, on=["component", "attribute"]) + expanded["coefficient"] = expanded["coefficient"] * expanded["sign"] + return expanded.loc[:, ["variable_name", "model_variable", "coefficient"]] + + +def _raise_if_no_variable_terms(variable_terms: pd.DataFrame, name: str) -> None: + """Raises when a linopy constraint has no LHS terms on model variables, as + when no LHS rows match its constraint_name and investment_period. Its LHS + would be empty. + + I/O Example: + variable_terms: + variable_name model_variable coefficient # no rows + + name: "SWQLD1_2025_qld_peak_demand" + + raises ValueError: "Custom constraint SWQLD1_2025_qld_peak_demand has + no LHS terms on model variables." + """ + if variable_terms.empty: + raise ValueError( + f"Custom constraint {name} has no LHS terms on model variables." + ) + + +def _sum_variable_terms( + model: linopy.Model, variable_terms: pd.DataFrame, scope: pd.MultiIndex +) -> linopy.LinearExpression: + """Sums coefficient x variable over the terms, restricting time-indexed + variables to the snapshots in scope. + + I/O Example: + variable_terms: + variable_name model_variable coefficient + NSW-QLD_existing Link-p 0.84 + NSW-QLD_exp_2025 Link-p_nom -1.0 + + scope: + investment_periods snapshots + 2025 2025-01-01 01:00 + + returns, at each snapshot t in scope: + 0.84 Link-p[t, NSW-QLD_existing] - 1.0 Link-p_nom[NSW-QLD_exp_2025] + """ + expressions = [ + term.coefficient + * _select_model_variable(model, term.model_variable, term.variable_name, scope) + for term in variable_terms.itertuples() + ] + return linopy.merge(expressions, cls=linopy.LinearExpression) + + +def _select_model_variable( + model: linopy.Model, model_variable: str, component_name: str, scope: pd.MultiIndex +) -> linopy.Variable: + """One component's model variable, restricted to the snapshots in scope when + it's time-indexed. + + I/O Example: + scope: + investment_periods snapshots + 2025 2025-01-01 01:00 + + model_variable: "Link-p", component_name: "NSW-QLD_existing" + returns: Link-p[(2025, 2025-01-01 01:00), NSW-QLD_existing] + + model_variable: "Link-p_nom", component_name: "NSW-QLD_exp_2025" + returns: Link-p_nom[NSW-QLD_exp_2025] # no snapshot dimension + """ + variable = model.variables[model_variable].sel(name=component_name) + if "snapshot" in variable.dims: + variable = variable.sel(snapshot=scope) + return variable + + +def _sum_load_terms( + network: pypsa.Network, load_terms: pd.DataFrame, scope: pd.MultiIndex +) -> pd.Series | float: + """Sums coefficient x p_set over the load terms at each snapshot in scope. + Returns 0 when there are none, so a p_nom-only constraint doesn't gain a + snapshot dimension. + + I/O Example: + load_terms: + variable_name coefficient + load_SQ -0.33 + + scope: + investment_periods snapshots + 2025 2025-01-01 01:00 + 2025 2025-01-01 02:00 + + network.loads_t.p_set: + period timestep load_SQ + 2025 2025-01-01 00:00 5000 # not in scope + 2025 2025-01-01 01:00 6000 + 2025 2025-01-01 02:00 7000 + + returns (a Series whose index is named "snapshot"): + snapshot value + (2025, 2025-01-01 01:00) -1980 + (2025, 2025-01-01 02:00) -2310 + """ + # A scalar 0 adds nothing and, unlike a series, doesn't give the + # expression a snapshot dimension. + if load_terms.empty: + return 0.0 + # Demand table: one row per snapshot in scope, one column per load term + # (in load_terms' order, repeating a load listed twice). + p_set = network.loads_t.p_set.loc[scope, load_terms["variable_name"]] + # Scale each column by its term's coefficient. to_numpy() multiplies by + # position, so duplicate load columns each get their own coefficient; + # then sum across the loads to one value per snapshot. + offset = (p_set * load_terms["coefficient"].to_numpy()).sum(axis=1) + # linopy turns the index into a dimension named after it. Unnamed, it + # becomes "dim_0" when the expression has no snapshot dimension of its + # own (a p_nom-only constraint with a load term), so name it "snapshot". + offset.index.name = "snapshot" + return offset + + +def _linopy_constraint_name(rhs_row: tuple) -> str: + """A name unique to a linopy constraint, built from its constraint_name + and whichever of investment_period and timeslice it has. + + I/O Example (rhs_row -> returns): + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand -> "SWQLD1_2025_qld_peak_demand" + SWQLD1 2025 , -> "SWQLD1_2025" + CQ-NQ_expansion_limit , , -> "CQ-NQ_expansion_limit" + """ + name_parts = [rhs_row.constraint_name] + if pd.notna(rhs_row.investment_period): + name_parts.append(str(int(rhs_row.investment_period))) + if pd.notna(rhs_row.timeslice): + name_parts.append(rhs_row.timeslice) + return "_".join(name_parts) + + +def _add_linopy_constraint( + model: linopy.Model, + expression: linopy.LinearExpression, + rhs_row: tuple, + name: str, +) -> None: + """Adds expression rhs to the model under the given name. + + I/O Example: + expression: 0.84 Link-p[t, NSW-QLD_existing] + + rhs_row: + rhs constraint_type + 3000 <= + + name: "SWQLD1_2025" + + adds to model: SWQLD1_2025: 0.84 Link-p[t, NSW-QLD_existing] <= 3000 + """ + model.add_constraints(expression, rhs_row.constraint_type, rhs_row.rhs, name=name) diff --git a/src/ispypsa/templater/existing_planned.py b/src/ispypsa/templater/existing_planned.py index 7a6e23a8..fc337526 100644 --- a/src/ispypsa/templater/existing_planned.py +++ b/src/ispypsa/templater/existing_planned.py @@ -2,10 +2,11 @@ summary into generator and storage tables, and builds the generator identity and property columns. -Both target tables — see schemas/generators_existing_planned.yaml and -schemas/storage_existing_planned.yaml — are built from the single IASR -existing_committed_anticipated_additional_generator_summary table, which already lists -one row per real generating/storage unit (DUID-level). TODO: finish templating storage. +Both target tables — see schemas/ispypsa_tables/generators_existing_planned.yaml +and schemas/ispypsa_tables/storage_existing_planned.yaml — are built from the +single IASR existing_committed_anticipated_additional_generator_summary table, +which already lists one row per real generating/storage unit (DUID-level). TODO: +finish templating storage. existing_committed_anticipated_additional_generator_summary: IASR ID / DLT names Power Station Technology Type REZ ID Sub-region Fuel type Fuel cost mapping diff --git a/src/ispypsa/templater/new_entrants.py b/src/ispypsa/templater/new_entrants.py index 90d41721..aa1879d7 100644 --- a/src/ispypsa/templater/new_entrants.py +++ b/src/ispypsa/templater/new_entrants.py @@ -3,7 +3,8 @@ Both tables are built from the IASR ``new_entrants_summary`` table (for identity columns) plus per-technology property tables. This module splits the summary into its two subsets and shapes each into the columns of its target schema (see -schemas/generators_new_entrant.yaml and schemas/storage_new_entrant.yaml). +schemas/ispypsa_tables/generators_new_entrant.yaml and +schemas/ispypsa_tables/storage_new_entrant.yaml). There are two independent public orchestrators, one per output table. Each one: 1. Filters the summary to its technology group (generators or storage) diff --git a/src/ispypsa/translator/timeslices.py b/src/ispypsa/translator/timeslices.py index 5d30f7d0..ddc58a89 100644 --- a/src/ispypsa/translator/timeslices.py +++ b/src/ispypsa/translator/timeslices.py @@ -164,20 +164,90 @@ def _log_referenced_timeslices_without_snapshots( custom_constraints_rhs: pd.DataFrame, ) -> None: """Logs the timeslices referenced by a limit or constraint but mapped to - no snapshots — those limits and constraints will never apply. + no snapshots where they would apply — those limits and constraints will + never apply. This is expected when snapshot aggregation (e.g. representative weeks) selects no snapshots inside a timeslice's windows, and for calendar timeslices that never activate (tas_peak_demand in the Draft 2026 ISP calendar), but the user should know the affected inputs will not bind. + + Transmission limits apply in every investment period, so they are checked + against the model as a whole. Custom constraint rows each apply in one + investment period, so they are checked period by period: a short + timeslice like qld_peak_demand can be caught by the representative weeks + in 2025 but missed in 2030. """ - referenced = set(link_timeslice_limits["timeslice"]) | set( - custom_constraints_rhs["timeslice"].dropna() + _log_link_timeslices_without_snapshots(timeslice_snapshots, link_timeslice_limits) + _log_constraint_timeslices_without_snapshots_in_period( + timeslice_snapshots, custom_constraints_rhs ) + + +def _log_link_timeslices_without_snapshots( + timeslice_snapshots: pd.DataFrame, link_timeslice_limits: pd.DataFrame +) -> None: + """Logs the named timeslices in link_timeslice_limits with no snapshots in + any investment period. Fallback rows (blank timeslice) aren't checked. + + I/O Example: + timeslice_snapshots: + timeslice investment_periods snapshots + nsw_peak_demand 2026 2026-01-13 12:00:00 + + link_timeslice_limits: + name attribute timeslice value + CQ-NQ_existing p_max_pu nsw_peak_demand 0.8 + CQ-NQ_existing p_max_pu tas_peak_demand 0.9 # never mapped: logged + CQ-NQ_existing p_max_pu , 1.0 # fallback: not checked + + logs: [...] will never apply): ['tas_peak_demand'] + """ + referenced = set(link_timeslice_limits["timeslice"].dropna()) without_snapshots = referenced - set(timeslice_snapshots["timeslice"]) if without_snapshots: logger.warning( - f"Timeslices referenced by transmission limits or custom constraints " - f"but with no snapshots in the model (these limits and constraints " - f"will never apply): {sorted(without_snapshots)}" + f"Timeslices referenced by transmission limits but with no snapshots " + f"in the model (these limits will never apply): " + f"{sorted(without_snapshots)}" + ) + + +def _log_constraint_timeslices_without_snapshots_in_period( + timeslice_snapshots: pd.DataFrame, custom_constraints_rhs: pd.DataFrame +) -> None: + """Logs each (timeslice, investment_period) pair named in + custom_constraints_rhs with no snapshots in timeslice_snapshots. Fallback + rows (blank timeslice) aren't checked; a named timeslice always has an + investment_period (custom_constraints_rhs schema). + + I/O Example: + timeslice_snapshots: + timeslice investment_periods snapshots + qld_peak_demand 2025 2025-01-31 12:00:00 + + custom_constraints_rhs: + constraint_name investment_period timeslice + SWQLD1 2025 qld_peak_demand + SWQLD1 2030 qld_peak_demand # not in 2030: logged + SWQLD1 2030 , # fallback: not checked + + logs: [...] will never apply): [('qld_peak_demand', 2030)] + """ + named = custom_constraints_rhs.dropna(subset=["timeslice"]) + referenced = set( + zip(named["timeslice"], named["investment_period"].astype(int).tolist()) + ) + mapped = set( + zip( + timeslice_snapshots["timeslice"], + timeslice_snapshots["investment_periods"].tolist(), + ) + ) + without_snapshots = referenced - mapped + if without_snapshots: + logger.warning( + f"Timeslices referenced by custom constraints but with no snapshots in " + f"the constraint's investment period (these constraints will never " + f"apply): {sorted(without_snapshots)}" ) diff --git a/src/ispypsa/validation/schemas/costs_connection.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/costs_connection.yaml similarity index 100% rename from src/ispypsa/validation/schemas/costs_connection.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/costs_connection.yaml diff --git a/src/ispypsa/validation/schemas/costs_fuel_prices.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/costs_fuel_prices.yaml similarity index 100% rename from src/ispypsa/validation/schemas/costs_fuel_prices.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/costs_fuel_prices.yaml diff --git a/src/ispypsa/validation/schemas/costs_new_entrant_build.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/costs_new_entrant_build.yaml similarity index 100% rename from src/ispypsa/validation/schemas/costs_new_entrant_build.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/costs_new_entrant_build.yaml diff --git a/src/ispypsa/validation/schemas/custom_constraints.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints.yaml similarity index 100% rename from src/ispypsa/validation/schemas/custom_constraints.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints.yaml diff --git a/src/ispypsa/validation/schemas/custom_constraints_lhs.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints_lhs.yaml similarity index 100% rename from src/ispypsa/validation/schemas/custom_constraints_lhs.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints_lhs.yaml diff --git a/src/ispypsa/validation/schemas/custom_constraints_rhs.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints_rhs.yaml similarity index 100% rename from src/ispypsa/validation/schemas/custom_constraints_rhs.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/custom_constraints_rhs.yaml diff --git a/src/ispypsa/validation/schemas/emissions_reduction.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/emissions_reduction.yaml similarity index 100% rename from src/ispypsa/validation/schemas/emissions_reduction.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/emissions_reduction.yaml diff --git a/src/ispypsa/validation/schemas/generators_existing_planned.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/generators_existing_planned.yaml similarity index 100% rename from src/ispypsa/validation/schemas/generators_existing_planned.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/generators_existing_planned.yaml diff --git a/src/ispypsa/validation/schemas/generators_new_entrant.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/generators_new_entrant.yaml similarity index 100% rename from src/ispypsa/validation/schemas/generators_new_entrant.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/generators_new_entrant.yaml diff --git a/src/ispypsa/validation/schemas/network_expansion_options.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/network_expansion_options.yaml similarity index 100% rename from src/ispypsa/validation/schemas/network_expansion_options.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/network_expansion_options.yaml diff --git a/src/ispypsa/validation/schemas/network_geography.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/network_geography.yaml similarity index 100% rename from src/ispypsa/validation/schemas/network_geography.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/network_geography.yaml diff --git a/src/ispypsa/validation/schemas/network_transmission_path_expansion_costs.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_path_expansion_costs.yaml similarity index 100% rename from src/ispypsa/validation/schemas/network_transmission_path_expansion_costs.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_path_expansion_costs.yaml diff --git a/src/ispypsa/validation/schemas/network_transmission_path_limits.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_path_limits.yaml similarity index 100% rename from src/ispypsa/validation/schemas/network_transmission_path_limits.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_path_limits.yaml diff --git a/src/ispypsa/validation/schemas/network_transmission_paths.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_paths.yaml similarity index 100% rename from src/ispypsa/validation/schemas/network_transmission_paths.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/network_transmission_paths.yaml diff --git a/src/ispypsa/validation/schemas/resource_limits.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/resource_limits.yaml similarity index 100% rename from src/ispypsa/validation/schemas/resource_limits.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/resource_limits.yaml diff --git a/src/ispypsa/validation/schemas/storage_existing_planned.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/storage_existing_planned.yaml similarity index 100% rename from src/ispypsa/validation/schemas/storage_existing_planned.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/storage_existing_planned.yaml diff --git a/src/ispypsa/validation/schemas/storage_new_entrant.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/storage_new_entrant.yaml similarity index 100% rename from src/ispypsa/validation/schemas/storage_new_entrant.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/storage_new_entrant.yaml diff --git a/src/ispypsa/validation/schemas/timeslices.yaml b/src/ispypsa/validation/schemas/ispypsa_tables/timeslices.yaml similarity index 100% rename from src/ispypsa/validation/schemas/timeslices.yaml rename to src/ispypsa/validation/schemas/ispypsa_tables/timeslices.yaml diff --git a/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_lhs.yaml b/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_lhs.yaml new file mode 100644 index 00000000..febc26a6 --- /dev/null +++ b/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_lhs.yaml @@ -0,0 +1,91 @@ +table: custom_constraints_lhs +required: false +unique: + - [constraint_name, investment_period, variable_name, component, attribute] +custom_validation: + - name: supported_component_attribute_pairs + description: > + Each row's (component, attribute) must be one pypsa_build maps onto the + model: (Link, p), (Link, p_nom), (Generator, p), (Generator, p_nom), + (Storage, p) or (Load, p_set). pypsa_build raises on any other pair, + listing every unsupported pair found. + - name: terms_belong_to_an_rhs_row + description: > + Every (constraint_name, investment_period) here must match a + custom_constraints_rhs row with the same pair, blank matching blank. So a + term with a blank investment_period needs its constraint to have an RHS + row with a blank investment_period, and a term with an investment_period + an RHS row in that investment period. pypsa_build picks each + constraint's terms from its RHS rows, so a term with no matching row is + silently ignored. The translator drops the investment periods in which a + constraint has only one side before this table is written. + - name: variables_name_components_in_the_input_tables + description: > + variable_name must name a component in the pypsa-friendly table + pypsa_build builds that component type from: a Link in links.name; a + Generator in generators.name or custom_constraints_generators.name (the + relaxation generators); a Storage unit in batteries.name; and a Load as + "load_", where is in buses.name and has a demand trace + (demand_traces/.parquet), since pypsa_build only attaches loads to + buses with one. A p_nom term must also name a row with + p_nom_extendable true, as only extendable components have a p_nom + variable. The translator raises on terms whose component isn't in the + model before this table is written. +description: > + Left-hand-side terms of each custom constraint: one row per (constraint, + investment period, model component) coefficient. + + Produced by ispypsa.translator.constraints, which expands each templated + term from its input ID into one term per matching PyPSA component (e.g. a + path's existing and expansion links) in each investment period the component + is in service. Consumed by + ispypsa.pypsa_build.custom_constraints._add_custom_constraints_with_temporal_scope. + + If absent: + No custom constraints are added to the model. +columns: + constraint_name: + type: string + required: true + allowed_values_from: + - custom_constraints_rhs: constraint_name + description: Constraint the term belongs to. + investment_period: + type: int + required: false + description: > + Investment period the term applies in. A term joins the constraints of + the custom_constraints_rhs rows with the same constraint_name and + investment_period. + + If absent (or empty): + The term applies regardless of investment period, joining its + constraint's RHS row with a blank investment_period. Only the expansion + limits take this form (see terms_belong_to_an_rhs_row). + variable_name: + type: string + required: true + description: > + Name of the PyPSA component the term's variable belongs to: a link, a + generator, a storage unit, or a load ("load_"). + component: + type: string + required: true + allowed_values: [Link, Generator, Storage, Load] + description: > + Type of the component variable_name names. Storage terms apply to + StorageUnit components. + attribute: + type: string + required: true + allowed_values: [p, p_nom, p_set] + description: > + The component's variable the term applies to: p is dispatch (a Link's + flow, a Generator's output, or a Storage unit's net dispatch, discharging + minus charging), p_nom is installed capacity, and p_set (Load only) is + demand. p_set is data rather than a variable: coefficient x demand moves + to the constraint's right-hand side at each snapshot. + coefficient: + type: float + required: true + description: Coefficient the variable is multiplied by in the constraint. diff --git a/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_rhs.yaml b/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_rhs.yaml new file mode 100644 index 00000000..edf5c05a --- /dev/null +++ b/src/ispypsa/validation/schemas/pypsa_friendly_tables/custom_constraints_rhs.yaml @@ -0,0 +1,76 @@ +table: custom_constraints_rhs +required: false +unique: + - [constraint_name, investment_period, timeslice] +custom_validation: + - name: blank_investment_period_rows_have_a_blank_timeslice + description: > + A row with a blank investment_period must also have a blank timeslice. + A blank investment_period is reserved for constraints that apply + regardless of time: in every investment period and at every snapshot, + so the timeslice must be blank too. Only the expansion limits take this + form. A row with a blank investment_period and a named timeslice would + match no snapshots, so pypsa_build would skip it and its limit would + silently never apply. + - name: constraint_investment_periods_have_lhs_terms + description: > + Every (constraint_name, investment_period) here must have at least one + custom_constraints_lhs term on a model variable (anything but a Load + p_set term) for the same pair, blank matching blank. pypsa_build raises + on a constraint with no such term, since its LHS would be empty. +description: > + Right-hand side of each custom constraint: one row per linopy constraint + pypsa_build adds, scoped in time by its investment_period and timeslice. + + Produced by ispypsa.translator.constraints, which resolves the templated + custom-constraint tables onto the investment periods and appends the + endogenous expansion-limit constraints. Consumed by + ispypsa.pypsa_build.custom_constraints._add_custom_constraints_with_temporal_scope. + + If absent: + No custom constraints are added to the model. +columns: + constraint_name: + type: string + required: true + description: > + Constraint the row belongs to: a templated constraint_id, or + "_expansion_limit" for an expansion-limit constraint. + investment_period: + type: int + required: false + description: > + Investment period the row applies in. Its LHS terms are the + custom_constraints_lhs rows with the same constraint_name and + investment_period. + + If absent (or empty): + The row applies regardless of investment period, and its LHS terms are + the ones with a blank investment_period. Only the expansion-limit + constraints take this form. + timeslice: + type: string + required: false + description: > + Timeslice the row applies in: the constraint holds at that timeslice's + snapshots (from timeslice_snapshots) in its investment_period. A + timeslice with no snapshots in timeslice_snapshots (e.g. one that never + activates, or one missed by snapshot aggregation) is valid; the + constraint just doesn't apply. + + If absent (or empty): + The row is the constraint's fallback: it applies at the snapshots in its + investment_period that none of the same constraint's named timeslices + cover. A constraint with only named-timeslice rows doesn't bind outside + them. + rhs: + type: float + required: true + description: > + Limit value the constraint's LHS is compared against, before any load + terms' per-snapshot demand is moved across. + constraint_type: + type: string + required: true + allowed_values: ["<=", ">=", "=="] + description: Comparison between the constraint's LHS and rhs. diff --git a/src/ispypsa/validation/schemas/pypsa_friendly_tables/timeslice_snapshots.yaml b/src/ispypsa/validation/schemas/pypsa_friendly_tables/timeslice_snapshots.yaml new file mode 100644 index 00000000..8df393d0 --- /dev/null +++ b/src/ispypsa/validation/schemas/pypsa_friendly_tables/timeslice_snapshots.yaml @@ -0,0 +1,40 @@ +table: timeslice_snapshots +required: false +unique: + - [timeslice, investment_periods, snapshots] +custom_validation: + - name: snapshots_are_in_the_snapshots_table + description: > + Every (investment_periods, snapshots) pair must be a row of the + pypsa-friendly snapshots table, which pypsa_build sets the network's + snapshots from. The translator builds this table from those same + snapshots, so the check guards the contract rather than a known gap; + pypsa_build selects model variables at these snapshots and would fail on + one the network doesn't have. +description: > + Which snapshots each timeslice is active at: one row per (timeslice, + snapshot) pair. A snapshot appears once per region's timeslice active at it, + since each region's timeslice windows tile the year. + + Produced by ispypsa.translator.timeslices, which re-sequences the templated + timeslice window patterns onto the model's snapshots. Consumed by + ispypsa.pypsa_build.links (per-timeslice link limits) and + ispypsa.pypsa_build.custom_constraints (named-timeslice custom constraints). + + If absent (or empty): + No snapshot belongs to a named timeslice, so only the fallback rows (blank + timeslice) of link_timeslice_limits and custom_constraints_rhs apply. +columns: + timeslice: + type: string + required: true + description: Timeslice active at the snapshot, e.g. qld_peak_demand. + investment_periods: + type: int + required: true + description: Investment period of the snapshot. + snapshots: + type: date + required: true + format: "%Y-%m-%d %H:%M:%S" + description: The snapshot's timestamp. diff --git a/tests/test_model/test_custom_constraints_with_temporal_scope.py b/tests/test_model/test_custom_constraints_with_temporal_scope.py new file mode 100644 index 00000000..a5238de0 --- /dev/null +++ b/tests/test_model/test_custom_constraints_with_temporal_scope.py @@ -0,0 +1,782 @@ +import numpy as np +import pandas as pd +import pypsa +import pytest + +from ispypsa.pypsa_build.custom_constraints import ( + _add_custom_constraints_with_temporal_scope, +) + +_RHS_COLUMNS = [ + "constraint_name", + "investment_period", + "timeslice", + "rhs", + "constraint_type", +] +_LHS_COLUMNS = [ + "constraint_name", + "investment_period", + "variable_name", + "component", + "attribute", + "coefficient", +] + + +def _network() -> pypsa.Network: + """Two investment periods of two hourly snapshots, with one of each + component a custom constraint term can reference, and the linopy model + built so constraints can be added.""" + periods = [2025, 2025, 2030, 2030] + snapshots = pd.to_datetime( + [ + "2025-01-01 00:00", + "2025-01-01 01:00", + "2030-01-01 00:00", + "2030-01-01 01:00", + ] + ) + index = pd.MultiIndex.from_arrays([periods, snapshots]) + network = pypsa.Network(snapshots=index, investment_periods=[2025, 2030]) + network.add("Bus", ["NQ", "SQ"]) + network.add("Link", "NSW-QLD_existing", bus0="NQ", bus1="SQ", p_nom=1000) + network.add( + "Link", + "NSW-QLD_exp_2025", + bus0="NQ", + bus1="SQ", + p_nom_extendable=True, + capital_cost=1, + ) + network.add("Generator", "KINGASF1", bus="SQ", p_nom=100, marginal_cost=1) + network.add("StorageUnit", "Q8 Battery - 2h", bus="SQ", p_nom=50, max_hours=2) + load = pd.Series([600.0, 700.0, 800.0, 900.0], index=network.snapshots) + network.add("Load", "load_SQ", bus="SQ", p_set=load) + network.optimize.create_model(multi_investment_periods=True) + return network + + +def _constraint_terms(network: pypsa.Network, name: str) -> pd.DataFrame: + """Flattens one named linopy constraint into a DataFrame for comparison + with assert_frame_equal: one row per (snapshot, variable) term, sorted. + + The snapshot is the constraint row's own, so it is blank only for a + constraint with no snapshot dimension (e.g. an expansion limit). The rhs + includes any load offset linopy moved across. + + Example row (0.84 x Link-p[NSW-QLD_existing] <= 3000 at 01:00): + investment_periods snapshots variable coefficient sign rhs + 2025 2025-01-01 01:00:00 Link-p[NSW-QLD_existing] 0.84 <= 3000 + """ + model = network.model + flat = model.constraints[name].flat + rows = [model.constraints.get_label_position(label) for label in flat["labels"]] + snapshots = [coords.get("snapshot", (np.nan, np.nan)) for _, coords in rows] + variables = [model.variables.get_label_position(label) for label in flat["vars"]] + terms = pd.DataFrame( + { + "investment_periods": [period for period, _ in snapshots], + "snapshots": [t if pd.isna(t) else str(t) for _, t in snapshots], + "variable": [f"{var}[{coords['name']}]" for var, coords in variables], + "coefficient": flat["coeffs"].to_numpy(), + "sign": flat["sign"].to_numpy(), + "rhs": flat["rhs"].to_numpy(), + } + ) + return terms.sort_values(["snapshots", "variable"]).reset_index(drop=True) + + +def _custom_constraint_names(network: pypsa.Network) -> list[str]: + """The names of the constraints not added by PyPSA itself.""" + pypsa_prefixes = ("Bus-", "Generator-", "Link-", "StorageUnit-", "Kirchhoff") + return sorted( + name + for name in network.model.constraints + if not name.startswith(pypsa_prefixes) + ) + + +def test_named_timeslice_binds_only_at_its_snapshots(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, qld_peak_demand, 3000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + qld_peak_demand, 2030, 2030-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + assert _custom_constraint_names(network) == ["SWQLD1_2025_qld_peak_demand"] + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3000 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2025_qld_peak_demand"), + expected, + check_dtype=False, + ) + + +def test_fallback_applies_only_where_named_timeslices_do_not(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, qld_peak_demand, 3000, <= + SWQLD1, 2025, , 3500, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + assert _custom_constraint_names(network) == [ + "SWQLD1_2025", + "SWQLD1_2025_qld_peak_demand", + ] + # The fallback holds at 2025's off-peak snapshot only: not at the peak + # snapshot its named row covers, and not in 2030. + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2025"), expected, check_dtype=False + ) + + +def test_fallback_ignores_other_constraints_named_timeslices(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, qld_peak_demand, 3000, <= + SWQLD2, 2025, , 3500, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + SWQLD2, 2025, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + # SWQLD1's peak timeslice doesn't carve 01:00 out of SWQLD2's fallback: + # SWQLD2 has no named rows, so its fallback covers all of 2025. + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + 2025, 2025-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD2_2025"), expected, check_dtype=False + ) + + +def test_fallback_only_constraint_applies_across_its_period(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2030, , 3500, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2030, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2030, 2030-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + 2030, 2030-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2030"), expected, check_dtype=False + ) + + +def test_lhs_terms_apply_only_in_their_own_investment_period(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, , 3500, <= + SWQLD1, 2030, , 3500, <= + """) + # The term with a blank investment_period matches neither RHS row, since + # both have an investment_period: a blank investment_period only pairs + # with a blank one (the expansion-limit shape). The translator doesn't + # currently emit a term with a blank investment_period alongside ones with + # an investment_period; it's included for completeness, to pin down that + # such a term is left out rather than added in every investment period. + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + SWQLD1, 2030, NSW-QLD_existing, Link, p, 0.9 + SWQLD1, 2025, KINGASF1, Generator, p, 0.14 + SWQLD1, , NSW-QLD_exp_2025, Link, p, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + expected_2025 = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 00:00:00, Generator-p[KINGASF1], 0.14, <=, 3500 + 2025, 2025-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + 2025, 2025-01-01 01:00:00, Generator-p[KINGASF1], 0.14, <=, 3500 + 2025, 2025-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3500 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2025"), expected_2025, check_dtype=False + ) + expected_2030 = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2030, 2030-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.9, <=, 3500 + 2030, 2030-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.9, <=, 3500 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2030"), expected_2030, check_dtype=False + ) + + +def test_expansion_limit_has_no_snapshot_dimension(csv_str_to_df): + # The translator's expansion-limit shape: blank investment_period and + # timeslice, p_nom terms only. An all-blank timeslice column reads as + # float64 (Open-ISP/ISPyPSA#138), which this also exercises. + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + NSW-QLD_expansion_limit, , , 1000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + NSW-QLD_expansion_limit, , NSW-QLD_exp_2025, Link, p_nom, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + , , Link-p_nom[NSW-QLD_exp_2025], 1.0, <=, 1000 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "NSW-QLD_expansion_limit"), + expected, + check_dtype=False, + ) + + +def test_storage_term_is_net_dispatch(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, qld_peak_demand, 3000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, Q8__Battery__-__2h, Storage, p, 0.43 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 01:00:00, StorageUnit-p_dispatch[Q8__Battery__-__2h], 0.43, <=, 3000 + 2025, 2025-01-01 01:00:00, StorageUnit-p_store[Q8__Battery__-__2h], -0.43, <=, 3000 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2025_qld_peak_demand"), + expected, + check_dtype=False, + ) + + +def test_load_term_offsets_the_rhs_at_each_snapshot(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, , 3000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + SWQLD1, 2025, load_SQ, Load, p_set, -0.33 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + # 3000 + 0.33 x load: 600 at 00:00, 700 at 01:00. + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 00:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3198 + 2025, 2025-01-01 01:00:00, Link-p[NSW-QLD_existing], 0.84, <=, 3231 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "SWQLD1_2025"), expected, check_dtype=False + ) + + +def test_load_term_gives_a_p_nom_only_constraint_a_snapshot_dimension( + csv_str_to_df, +): + # Build at least 10% of demand: the load term varies by snapshot, so the + # capacity-only constraint holds once per snapshot. The rows must sit on + # the network's "snapshot" dimension: on a stray unnamed one, their + # snapshots would come back blank and the comparison would fail. The + # translator doesn't currently emit a p_nom-only constraint with a load + # term; this pins down how the current implementation handles one. + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOOR, 2025, , 0, >= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOOR, 2025, NSW-QLD_exp_2025, Link, p_nom, 1.0 + FLOOR, 2025, load_SQ, Load, p_set, -0.1 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + # 0 + 0.1 x load: 600 at 00:00, 700 at 01:00. + expected = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + 2025, 2025-01-01 00:00:00, Link-p_nom[NSW-QLD_exp_2025], 1.0, >=, 60 + 2025, 2025-01-01 01:00:00, Link-p_nom[NSW-QLD_exp_2025], 1.0, >=, 70 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "FLOOR_2025"), expected, check_dtype=False + ) + + +def test_constraint_types_set_the_sign(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOOR, , , 10, >= + FIXED, , , 20, == + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOOR, , NSW-QLD_exp_2025, Link, p_nom, 1.0 + FIXED, , NSW-QLD_exp_2025, Link, p_nom, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + expected_floor = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + , , Link-p_nom[NSW-QLD_exp_2025], 1.0, >=, 10 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "FLOOR"), expected_floor, check_dtype=False + ) + expected_fixed = csv_str_to_df(""" + investment_periods, snapshots, variable, coefficient, sign, rhs + , , Link-p_nom[NSW-QLD_exp_2025], 1.0, =, 20 + """) + pd.testing.assert_frame_equal( + _constraint_terms(network, "FIXED"), expected_fixed, check_dtype=False + ) + + +def test_named_timeslice_with_no_snapshots_is_skipped(csv_str_to_df): + # e.g. tas_peak_demand, which never activates in the Draft 2026 ISP calendar. + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, tas_peak_demand, 3000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + assert _custom_constraint_names(network) == [] + + +def test_unsupported_term_raises(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, , 3000, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, SQ, Bus, p, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + with pytest.raises( + ValueError, + match=r"unsupported \(component, attribute\): \[\('Bus', 'p'\)\]", + ): + _add_custom_constraints_with_temporal_scope( + network, rhs, lhs, timeslice_snapshots + ) + + +def test_rhs_row_without_variable_terms_raises(csv_str_to_df): + network = _network() + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2025, , 3000, <= + """) + lhs = pd.DataFrame(columns=_LHS_COLUMNS) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + with pytest.raises( + ValueError, + match="Custom constraint SWQLD1_2025 has no LHS terms on model variables.", + ): + _add_custom_constraints_with_temporal_scope( + network, rhs, lhs, timeslice_snapshots + ) + + +def test_empty_rhs_adds_no_constraints(csv_str_to_df): + network = _network() + rhs = pd.DataFrame(columns=_RHS_COLUMNS) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + SWQLD1, 2025, NSW-QLD_existing, Link, p, 0.84 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + assert _custom_constraint_names(network) == [] + + +def test_both_tables_empty_adds_no_constraints(): + network = _network() + rhs = pd.DataFrame(columns=_RHS_COLUMNS) + lhs = pd.DataFrame(columns=_LHS_COLUMNS) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + + assert _custom_constraint_names(network) == [] + + +def _two_bus_network( + snapshots: list[tuple[int, str]], load: list[float], link_p_nom: float = 100 +) -> pypsa.Network: + """A small network to solve: cheap generation at NQ and expensive + generation at SQ, joined by the NSW-QLD_existing link, serving the SQ load + given per snapshot. Unconstrained, the link carries the whole load, so a + custom constraint on the link shows up as the expensive generator making + up the difference. + + snapshots are (investment period, timestamp) pairs, e.g. + [(2025, "2025-01-01 00:00"), (2025, "2025-01-01 01:00")]. + """ + periods = [period for period, _ in snapshots] + timestamps = pd.to_datetime([timestamp for _, timestamp in snapshots]) + index = pd.MultiIndex.from_arrays([periods, timestamps]) + network = pypsa.Network(snapshots=index, investment_periods=sorted(set(periods))) + network.add("Bus", ["NQ", "SQ"]) + network.add("Link", "NSW-QLD_existing", bus0="NQ", bus1="SQ", p_nom=link_p_nom) + network.add("Generator", "cheap", bus="NQ", p_nom=100, marginal_cost=1) + network.add("Generator", "expensive", bus="SQ", p_nom=100, marginal_cost=100) + network.add("Load", "load_SQ", bus="SQ", p_set=pd.Series(load, index=index)) + return network + + +_ONE_PERIOD = [(2025, "2025-01-01 00:00"), (2025, "2025-01-01 01:00")] +_TWO_PERIODS = _ONE_PERIOD + [(2030, "2030-01-01 00:00"), (2030, "2030-01-01 01:00")] + + +def test_peak_only_constraint_binds_at_peak_in_the_solved_model(csv_str_to_df): + # Cheap generation at NQ serves the SQ load over the link; a peak-only + # cap on the link's flow forces the expensive SQ generator on at the peak + # snapshot only. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOWCAP, 2025, qld_peak_demand, 20, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOWCAP, 2025, NSW-QLD_existing, Link, p, 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + flows = network.links_t.p0["NSW-QLD_existing"].reset_index(drop=True) + expected = pd.Series([50.0, 20.0], name="NSW-QLD_existing") + pd.testing.assert_series_equal(flows, expected, check_names=False) + + +def test_named_and_fallback_rows_split_a_period_in_the_solved_model( + csv_str_to_df, +): + # Each snapshot is bound by exactly one row: the peak cap at 01:00 and + # the fallback cap everywhere else in 2025. Both applying at a snapshot, + # or neither, would show up as a 20 or a 50 in the wrong place. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOWCAP, 2025, qld_peak_demand, 20, <= + FLOWCAP, 2025, , 40, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOWCAP, 2025, NSW-QLD_existing, Link, p, 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + flows = network.links_t.p0["NSW-QLD_existing"].reset_index(drop=True) + expected = pd.Series([40.0, 20.0]) + pd.testing.assert_series_equal(flows, expected, check_names=False, atol=1e-6) + + +def test_each_investment_period_uses_its_own_rhs_in_the_solved_model( + csv_str_to_df, +): + network = _two_bus_network(_TWO_PERIODS, load=[50, 50, 50, 50]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOWCAP, 2025, , 20, <= + FLOWCAP, 2030, , 40, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOWCAP, 2025, NSW-QLD_existing, Link, p, 1.0 + FLOWCAP, 2030, NSW-QLD_existing, Link, p, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + flows = network.links_t.p0["NSW-QLD_existing"].reset_index(drop=True) + expected = pd.Series([20.0, 20.0, 40.0, 40.0]) + pd.testing.assert_series_equal(flows, expected, check_names=False, atol=1e-6) + + +def test_expansion_limit_caps_the_build_in_the_solved_model(csv_str_to_df): + # With no existing link capacity, building the expansion link is far + # cheaper than running the expensive generator, so unconstrained it would + # be built to carry the whole 50 MW load. The expansion limit (blank + # investment_period and timeslice, p_nom term) caps it at 30. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50], link_p_nom=0) + network.add( + "Link", + "NSW-QLD_exp_2025", + bus0="NQ", + bus1="SQ", + p_nom_extendable=True, + capital_cost=1, + ) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + NSW-QLD_expansion_limit, , , 30, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + NSW-QLD_expansion_limit, , NSW-QLD_exp_2025, Link, p_nom, 1.0 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + built = network.links.loc[["NSW-QLD_exp_2025"], "p_nom_opt"] + expected = pd.Series([30.0], index=["NSW-QLD_exp_2025"]) + pd.testing.assert_series_equal(built, expected, check_names=False, atol=1e-6) + + +def test_load_term_scales_the_limit_with_demand_in_the_solved_model( + csv_str_to_df, +): + # flow - 0.5 x load <= 0, i.e. the link carries at most half the load. + # The load term moves to the right-hand side, so a sign error there would + # loosen or invert the limit rather than track demand. + network = _two_bus_network(_ONE_PERIOD, load=[40, 60]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + HALFLOAD, 2025, , 0, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + HALFLOAD, 2025, NSW-QLD_existing, Link, p, 1.0 + HALFLOAD, 2025, load_SQ, Load, p_set, -0.5 + """) + timeslice_snapshots = pd.DataFrame( + columns=["timeslice", "investment_periods", "snapshots"] + ) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + flows = network.links_t.p0["NSW-QLD_existing"].reset_index(drop=True) + expected = pd.Series([20.0, 30.0]) + pd.testing.assert_series_equal(flows, expected, check_names=False, atol=1e-6) + + +def test_generator_floor_binds_only_in_its_timeslice_in_the_solved_model( + csv_str_to_df, +): + # A >= row on a generator term: the expensive generator, idle when + # unconstrained, must run at 10 MW at the peak and nowhere else. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + FLOOR, 2025, qld_peak_demand, 10, >= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + FLOOR, 2025, expensive, Generator, p, 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + dispatch = network.generators_t.p["expensive"].reset_index(drop=True) + expected = pd.Series([0.0, 10.0]) + pd.testing.assert_series_equal(dispatch, expected, check_names=False, atol=1e-6) + + +def test_storage_term_constrains_net_dispatch_in_the_solved_model(csv_str_to_df): + # Net dispatch (discharging minus charging) >= 10 at the peak. The + # battery starts empty, so it must charge 10 at 00:00 to discharge 10 at + # 01:00. Its dispatch cost stops it cycling more than it has to. Were + # charging counted as positive, charging 10 at the peak would satisfy + # the constraint instead, giving [0, -10]. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50]) + network.add( + "StorageUnit", "battery", bus="SQ", p_nom=50, max_hours=2, marginal_cost=0.1 + ) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + DISCHARGE, 2025, qld_peak_demand, 10, >= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + DISCHARGE, 2025, battery, Storage, p, 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + net_dispatch = network.storage_units_t.p["battery"].reset_index(drop=True) + expected = pd.Series([-10.0, 10.0]) + pd.testing.assert_series_equal(net_dispatch, expected, check_names=False, atol=1e-6) + + +def test_overlapping_timeslice_constraints_both_bind_in_the_solved_model( + csv_str_to_df, +): + # Regions' timeslices overlap: nsw_summer_typical covers both snapshots + # and qld_peak_demand covers 01:00. Each constraint binds over its own + # timeslice, so at 01:00 both apply and the tighter QLD cap wins. + network = _two_bus_network(_ONE_PERIOD, load=[50, 50]) + network.optimize.create_model(multi_investment_periods=True) + rhs = csv_str_to_df(""" + constraint_name, investment_period, timeslice, rhs, constraint_type + QLDCAP, 2025, qld_peak_demand, 20, <= + NSWCAP, 2025, nsw_summer_typical, 30, <= + """) + lhs = csv_str_to_df(""" + constraint_name, investment_period, variable_name, component, attribute, coefficient + QLDCAP, 2025, NSW-QLD_existing, Link, p, 1.0 + NSWCAP, 2025, NSW-QLD_existing, Link, p, 1.0 + """) + timeslice_snapshots = csv_str_to_df(""" + timeslice, investment_periods, snapshots + nsw_summer_typical, 2025, 2025-01-01 00:00:00 + nsw_summer_typical, 2025, 2025-01-01 01:00:00 + qld_peak_demand, 2025, 2025-01-01 01:00:00 + """) + + _add_custom_constraints_with_temporal_scope(network, rhs, lhs, timeslice_snapshots) + network.optimize.solve_model() + + flows = network.links_t.p0["NSW-QLD_existing"].reset_index(drop=True) + expected = pd.Series([30.0, 20.0]) + pd.testing.assert_series_equal(flows, expected, check_names=False, atol=1e-6) diff --git a/tests/test_translator/test_timeslice_snapshots.py b/tests/test_translator/test_timeslice_snapshots.py index 448ddb69..e5a1dcc8 100644 --- a/tests/test_translator/test_timeslice_snapshots.py +++ b/tests/test_translator/test_timeslice_snapshots.py @@ -326,7 +326,18 @@ def test_empty_timeslices_table(csv_str_to_df): pd.testing.assert_frame_equal(result, expected, check_dtype=False) -def test_logs_referenced_timeslices_without_snapshots(csv_str_to_df, caplog): +_LINK_TIMESLICE_LIMIT_COLUMNS = ["name", "attribute", "timeslice", "value"] +_CUSTOM_CONSTRAINTS_RHS_COLUMNS = [ + "constraint_name", + "investment_period", + "timeslice", + "rhs", + "constraint_type", +] + + +def test_logs_link_timeslices_without_snapshots(csv_str_to_df, caplog): + # The blank-timeslice fallback row isn't a timeslice, so it isn't reported. timeslice_snapshots = _snapshots( csv_str_to_df, """ @@ -338,11 +349,42 @@ def test_logs_referenced_timeslices_without_snapshots(csv_str_to_df, caplog): name, attribute, timeslice, value CQ-NQ_existing, p_max_pu, nsw_peak_demand, 0.8 CQ-NQ_existing, p_max_pu, tas_peak_demand, 0.9 + CQ-NQ_existing, p_max_pu, , 1.0 """) + custom_constraints_rhs = pd.DataFrame(columns=_CUSTOM_CONSTRAINTS_RHS_COLUMNS) + + with caplog.at_level("WARNING"): + _log_referenced_timeslices_without_snapshots( + timeslice_snapshots, link_timeslice_limits, custom_constraints_rhs + ) + + assert ( + "Timeslices referenced by transmission limits but with no snapshots " + "in the model (these limits will never apply): ['tas_peak_demand']" + ) in caplog.text + + +def test_logs_constraint_timeslices_without_snapshots_in_their_period( + csv_str_to_df, caplog +): + # qld_peak_demand has snapshots in 2026 but not 2030 (e.g. representative + # weeks missed the peak day), vic_peak_demand has none at all. Blank-timeslice + # rows (a fallback and an expansion limit) aren't timeslices, so aren't reported. + timeslice_snapshots = _snapshots( + csv_str_to_df, + """ + timeslice, investment_periods, snapshots + qld_peak_demand, 2026, 2026-01-13 12:00:00 + """, + ) + link_timeslice_limits = pd.DataFrame(columns=_LINK_TIMESLICE_LIMIT_COLUMNS) custom_constraints_rhs = csv_str_to_df(""" - constraint_name, investment_period, timeslice, rhs, constraint_type - SWQLD1, 2026, vic_peak_demand, 3000, <= - CQ-NQ_expansion_limit, , , 1000, <= + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2026, qld_peak_demand, 3000, <= + SWQLD1, 2030, qld_peak_demand, 3200, <= + SWQLD1, 2030, , 3500, <= + VIC1, 2026, vic_peak_demand, 2000, <= + CQ-NQ_expansion_limit, , , 1000, <= """) with caplog.at_level("WARNING"): @@ -351,13 +393,14 @@ def test_logs_referenced_timeslices_without_snapshots(csv_str_to_df, caplog): ) assert ( - "Timeslices referenced by transmission limits or custom constraints " - "but with no snapshots in the model (these limits and constraints " - "will never apply): ['tas_peak_demand', 'vic_peak_demand']" + "Timeslices referenced by custom constraints but with no snapshots in " + "the constraint's investment period (these constraints will never " + "apply): [('qld_peak_demand', 2030), ('vic_peak_demand', 2026)]" ) in caplog.text def test_no_log_when_all_referenced_timeslices_have_snapshots(csv_str_to_df, caplog): + # Fallback rows on both tables must not be mistaken for unmapped timeslices. timeslice_snapshots = _snapshots( csv_str_to_df, """ @@ -368,10 +411,13 @@ def test_no_log_when_all_referenced_timeslices_have_snapshots(csv_str_to_df, cap link_timeslice_limits = csv_str_to_df(""" name, attribute, timeslice, value CQ-NQ_existing, p_max_pu, nsw_peak_demand, 0.8 + CQ-NQ_existing, p_max_pu, , 1.0 """) custom_constraints_rhs = csv_str_to_df(""" - constraint_name, investment_period, timeslice, rhs, constraint_type - SWQLD1, 2026, nsw_peak_demand, 3000, <= + constraint_name, investment_period, timeslice, rhs, constraint_type + SWQLD1, 2026, nsw_peak_demand, 3000, <= + SWQLD1, 2026, , 3500, <= + CQ-NQ_expansion_limit, , , 1000, <= """) with caplog.at_level("WARNING"):