From 92cbd807bfc5e8d8fa554f57a71a0f733819cc1c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 13:57:32 +0200 Subject: [PATCH 01/75] docs(pypsa): a single file covers the standard, stochastic and multi-period classes Fold pypsa_stochastic and pypsa_multi_period into examples/pypsa.yaml; retire the sibling pages, symbols and example files. Add the rung 14 and rung 15 sections to the gallery page, and guard the plain-run collapse to the standard model with a frozen shape fixture. --- .prettierignore | 2 - docs/examples/pypsa.md | 839 ++++++++++++------ docs/examples/pypsa_multi_period.md | 372 -------- docs/examples/pypsa_stochastic.md | 365 -------- examples/pypsa.yaml | 482 ++++++---- examples/pypsa_multi_period.yaml | 229 ----- examples/pypsa_stochastic.yaml | 220 ----- .../references/pypsa/rung_14_stochastic.py | 4 +- .../references/pypsa/rung_15_multi_period.py | 3 +- examples/symbols/pypsa.yaml | 23 + examples/symbols/pypsa_multi_period.yaml | 40 - examples/symbols/pypsa_stochastic.yaml | 39 - mkdocs.yml | 2 - tests/fixtures/pypsa_standard_shape.yaml | 607 +++++++++++++ tests/test_pypsa_collapses.py | 96 ++ tools/gallery.py | 2 - 16 files changed, 1585 insertions(+), 1740 deletions(-) delete mode 100644 docs/examples/pypsa_multi_period.md delete mode 100644 docs/examples/pypsa_stochastic.md delete mode 100644 examples/pypsa_multi_period.yaml delete mode 100644 examples/pypsa_stochastic.yaml delete mode 100644 examples/symbols/pypsa_multi_period.yaml delete mode 100644 examples/symbols/pypsa_stochastic.yaml create mode 100644 tests/fixtures/pypsa_standard_shape.yaml create mode 100644 tests/test_pypsa_collapses.py diff --git a/.prettierignore b/.prettierignore index 66add10f..1aa182c8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,8 +27,6 @@ docs/examples/pypsa.md docs/examples/pypsa_quadratic.md docs/examples/pypsa_linearized_uc.md docs/examples/pypsa_losses.md -docs/examples/pypsa_stochastic.md -docs/examples/pypsa_multi_period.md # The PyPSA reference scripts write this file; prettier would reformat what # they stamp, and the two would fight over it exactly as above. diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 921b046f..9e481055 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -6,11 +6,16 @@ SPDX-License-Identifier: CC-BY-4.0 # PyPSA in one file The model a plain `n.optimize()` builds, stated as one file and grown a rung -at a time. The index below lists every row PyPSA emits (PyPSA `1.3.0`, -`pypsa/optimization/`) and links each to its block in the file. +at a time. The file also carries the two classes PyPSA switches on with a +keyword: the two-stage stochastic class over a `scenario` axis (rung 14), and +the multi-period investment class over a `period` axis (rung 15). A plain run +feeds one scenario and one all-active period, so every extra axis collapses and +the standard model returns. The index below lists every row PyPSA emits (PyPSA +`1.3.0`, `pypsa/optimization/`) and links each to its block in the file. Three rules shape the file. Bounds are the explicit rows PyPSA writes, so -their duals are row duals. Regimes are data columns and `where:` masks. Names are PyPSA's, `Component_attribute`, with a symbol table +their duals are row duals. Regimes are data columns and `where:` masks. Names +are PyPSA's, `Component_attribute`, with a symbol table (`examples/symbols/pypsa.yaml`) making the math read as math. ## Index @@ -414,7 +419,7 @@ each type is three blocks by sense. | [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) | split | a block per sense | | [`tech_capacity_expansion_limit`](#tech_capacity_expansion_limit) | split | a block per sense | | `Bus-nom_min/max_{carrier}` | out | deprecated in PyPSA | -| [`Carrier-growth_limit`](pypsa_multi_period.md) | done | rung 15, a file of its own | +| [`Carrier-growth_limit`](#carrier-growth_limit) | done | rung 15, below | > ✔ `pypsa 1.3.0` solves this rung's network at objective `10282.833333333332`, 102 rows. @@ -1095,6 +1100,166 @@ def build(): +### Rung 14 — two-stage stochastic + +Two futures and a risk preference: `n.set_scenarios(...)` with +`n.set_risk_preference(alpha, omega)`. Everything over a snapshot spans a +scenario as well. Capacity does not, because it is chosen once before the +future is known. The operating cost is the expectation over the scenarios' +weights. A risk preference adds the CVaR (conditional value at risk) rows: an +excess per scenario and the tail's average, blended into the objective at +`omega`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-p`, `Link-p`](#variable-domains) | done | over `scenario`; `Generator-p_nom` is not — chosen once | +| [`Generator-fix-p-*`, `-ext-p-*`, `Link-fix-p-*`, `Bus-nodal_balance`](#generator-fix-p-lower) | done | rungs 1 and 3, over `scenario` | +| [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | +| [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | +| [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | +| [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. + +
+The network, as PyPSA code + +`rung_14_stochastic.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 14: two futures and a risk preference — capacity chosen once, dispatch per scenario.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an extendable wind unit whose availability and the south's load differ between a calm and a stormy future.""" + n = spine.build() + n.add('Generator', 'wind14', bus='south', p_nom_extendable=True, p_nom_max=100, marginal_cost=1, capital_cost=20) + n.add('Load', 'port14', bus='south') + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + n.c.loads.dynamic.p_set[('calm', 'port14')] = [10, 20, 15, 10] + n.c.loads.dynamic.p_set[('stormy', 'port14')] = [40, 60, 50, 30] + n.c.generators.dynamic.p_max_pu[('calm', 'wind14')] = [0.9, 0.7, 0.8, 0.6] + n.c.generators.dynamic.p_max_pu[('stormy', 'wind14')] = [0.3, 0.2, 0.4, 0.1] + n.set_risk_preference(alpha=0.5, omega=0.3) + return n +``` + +
+ + +### Rung 15 — investment periods + +`n.optimize(multi_investment_periods=True)`. A snapshot belongs to an +investment period. An asset stands in the periods its build year and lifetime +span. Capacity is paid once per period the asset stands in, and each period +carries a weight. A carrier may grow only so much per period. Which snapshots +an asset is active in is data prep, because a `where` reaches only the frame's +own dimensions. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | +| [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | +| [`Carrier-growth_limit`](#carrier-growth_limit) | done | counted in the first period a build stands in; `edge=0` at the first period | +| [objective](#objective) | done | period weight on operation; capacity once per period it stands in | +| `StorageUnit-energy_balance` per period, ramps at period starts | out | `shift(…, by=snapshot_period, within=period)` has them; a later rung | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. + +
+The network, as PyPSA code + +`rung_15_multi_period.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 15: two investment periods — build years, lifetimes, period weights and a carrier's growth limit.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, a unit that retires, two wind builds capped by growth.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] + n.add('Bus', ['north', 'south']) + n.add('Carrier', 'wind', max_growth=50, max_relative_growth=0.5) + n.add('Carrier', 'gas') + n.add('Generator', 'old_gas', bus='north', carrier='gas', p_nom=40, marginal_cost=30, build_year=2010, lifetime=15) + n.add( + 'Generator', + 'wind20', + bus='north', + carrier='wind', + p_nom_extendable=True, + p_nom_max=200, + marginal_cost=1, + capital_cost=100, + build_year=2020, + lifetime=30, + p_max_pu=[0.8, 0.6, 0.7, 0.5, 0.8, 0.6, 0.7, 0.5], + ) + n.add( + 'Generator', + 'wind30', + bus='south', + carrier='wind', + p_nom_extendable=True, + p_nom_max=200, + marginal_cost=1, + capital_cost=80, + build_year=2030, + lifetime=30, + p_max_pu=[0.9, 0.7, 0.6, 0.8, 0.9, 0.7, 0.6, 0.8], + ) + n.add( + 'Generator', + 'gas30', + bus='south', + carrier='gas', + p_nom_extendable=True, + p_nom_max=200, + marginal_cost=40, + capital_cost=50, + build_year=2030, + lifetime=30, + ) + n.add('Link', 'wire15', bus0='north', bus1='south', p_nom=60, p_min_pu=-1, efficiency=0.95) + n.add('Load', 'town15', bus='north', p_set=[20, 30, 25, 20, 35, 45, 40, 30]) + n.add('Load', 'port15', bus='south', p_set=[10, 20, 15, 10, 30, 40, 35, 25]) + return n +``` + +
+ + ### Rung 16 — link delay A source feeding two sinks over links whose energy arrives late. PyPSA's @@ -1183,7 +1348,6 @@ def build(): | PyPSA | status | note | | ------------------------------ | ------ | ------------------------------------ | | [`{c}-loss*`](pypsa_losses.md) | done | rung 13, a file of its own; tangent form | -| [`CVaR-*`](pypsa_stochastic.md) | done | rung 14, a file of its own | ## Refusals @@ -1207,15 +1371,16 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -The model a plain `n.optimize()` builds, stated in one file. Every declaration is named `Component_attribute` after the PyPSA statement it stands for, and each constraint's description opens with the linopy name PyPSA gives that row, so the two can be read side by side. PyPSA's regimes — extendable, committable — are data columns and become `where:` masks. Bounds are the explicit rows PyPSA writes, so their duals are row duals. Parameters no PyPSA table carries verbatim are computed in data prep and say so in their description. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. Which snapshots an asset is active in, and a scenario's weight, are data prep. #### Sets | Symbol | Meaning | |---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | | $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | @@ -1224,6 +1389,8 @@ The model a plain `n.optimize()` builds, stated in one file. Every declaration i | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | | $`\mathcal{B}`$ | index $`b`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | #### Parameters @@ -1261,7 +1428,24 @@ The model a plain `n.optimize()` builds, stated in one file. Every declaration i | $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once | | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the horizon's end — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at the first snapshots is lost | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | +| $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | +| $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | +| $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | +| $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | +| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | +| $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{on}^{h}`$ | `StorageUnit_active` over $`\mathcal{T} \times \mathcal{S}`$ — whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{on}^{e}`$ | `Store_active` over $`\mathcal{T} \times \mathcal{V}`$ — whether a store stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{on}^{s}`$ | `Line_active` over $`\mathcal{T} \times \mathcal{K}`$ — whether a line stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}`$ | `Generator_capital_weight` over $`\mathcal{G}`$ — the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{f}`$ | `Link_capital_weight` over $`\mathcal{L}`$ — the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{h}`$ | `StorageUnit_capital_weight` over $`\mathcal{S}`$ — the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{e}`$ | `Store_capital_weight` over $`\mathcal{V}`$ — the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{s}`$ | `Line_capital_weight` over $`\mathcal{K}`$ — the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit | +| $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top | | $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | | $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | | $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | @@ -1343,43 +1527,47 @@ The model a plain `n.optimize()` builds, stated in one file. Every declaration i | Symbol | Meaning | |---|---| -| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | -| $`f`$ | `Link_p` over $`\mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | -| $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | -| $`h^{-}`$ | `StorageUnit_p_store` over $`\mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | -| $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | -| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\mathcal{T} \times \mathcal{S}`$ — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | -| $`e`$ | `Store_e` over $`\mathcal{T} \times \mathcal{V}`$ — `Store-e` — energy held at the end of a snapshot | -| $`q`$ | `Store_p` over $`\mathcal{T} \times \mathcal{V}`$ — `Store-p` — power delivered to the bus; charging is negative | +| $`p`$ | `Generator_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | +| $`f`$ | `Link_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | +| $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | +| $`h^{-}`$ | `StorageUnit_p_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | +| $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | +| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | +| $`e`$ | `Store_e` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-e` — energy held at the end of a snapshot | +| $`q`$ | `Store_p` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-p` — power delivered to the bus; charging is negative | | $`N`$ | `Generator_n_mod` over $`\mathcal{G}`$ — `Generator-n_mod` — how many modules of an extendable modular build | -| $`u`$ | `Generator_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | -| $`\mathit{up}`$ | `Generator_start_up` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | -| $`\mathit{dn}`$ | `Generator_shut_down` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | -| $`s`$ | `Line_s` over $`\mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | +| $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | +| $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | +| $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ — `Link-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`H`$ | `StorageUnit_p_nom_ext` over $`\mathcal{S}`$ — `StorageUnit-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`E`$ | `Store_e_nom_ext` over $`\mathcal{V}`$ — `Store-e_nom` — nominal capacity where it is a decision; the parameter of the same PyPSA name carries the fixed regime | +| $`a`$ | `CVaR_a` over $`\Xi`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | +| $`\theta`$ | `CVaR_theta` (scalar) — `CVaR-theta` — where the tail starts, the value at risk | +| $`CVaR`$ | `CVaR` (scalar) — `CVaR` — the tail's average cost, what the objective prices at `omega` | #### Definitions | Symbol | Meaning | |---|---| -| $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | -| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | +| $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | | $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | -| $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | -| $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | +| $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | -| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | -| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | -| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\mathcal{B}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\mathcal{B}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | +| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | +| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{B}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{B}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{B}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{B}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{B}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | $`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. @@ -1392,27 +1580,19 @@ $`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own ord ```yaml objective: sense: minimize - description: operating cost, each snapshot weighted by the hours it stands for + description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective) - + sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective) - + sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective) - + sum(Store_p * Store_marginal_cost * snapshot_weightings_objective) - + sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective) - + sum(Generator_p_nom_ext * Generator_capital_cost) - + sum(Link_p_nom_ext * Link_capital_cost) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost) - + sum(Store_e_nom_ext * Store_capital_cost) - + sum(Line_s_nom_ext * Line_capital_cost) - + sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective) - + sum(Generator_start_up * Generator_start_up_cost) - + sum(Generator_shut_down * Generator_shut_down_cost) + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR ``` ```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ l \in \mathcal{L}} f_{t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ s \in \mathcal{S}} h^{+}_{t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ s \in \mathcal{S}} \mathit{soc}_{t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ s \in \mathcal{S}} \mathit{spill}_{t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ v \in \mathcal{V}} q_{t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ v \in \mathcal{V}} e_{t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} + \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} u_{t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{up}_{t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dn}_{t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} +\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR ``` ### `Generator-fix-p-lower` @@ -1422,13 +1602,13 @@ objective: ```yaml Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-fix-p-upper` @@ -1438,13 +1618,13 @@ p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qq ```yaml Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Link-fix-p-lower` @@ -1454,13 +1634,13 @@ p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qqu ```yaml Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - where: not Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom ``` ```math -f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-fix-p-upper` @@ -1470,13 +1650,13 @@ f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qq ```yaml Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" - dims: [snapshot, link] - where: not Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom ``` ```math -f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Generator-ext-p-lower` @@ -1486,13 +1666,13 @@ f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qqu ```yaml Generator_ext_p_lower: description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p-upper` @@ -1502,13 +1682,13 @@ p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mat ```yaml Generator_ext_p_upper: description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p_nom-lower` @@ -1566,13 +1746,13 @@ P_{g} = \mathrm{p}^{\mathrm{nom,set}}_{g} \qquad \forall\, g \in \mathcal{G} \,: ```yaml Generator_e_sum_min: description: "`Generator-e_sum_min` — energy over the horizon is at least its floor; a floor of minus infinity is no row" - dims: [generator] + dims: [scenario, generator] where: Generator_e_sum_min expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) >= Generator_e_sum_min ``` ```math -\sum_{t \in \mathcal{T}} p_{t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \ge \underline{\mathrm{E}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \underline{\mathrm{E}}_{g} \text{ is defined} +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \ge \underline{\mathrm{E}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \underline{\mathrm{E}}_{g} \text{ is defined} ``` ### `Generator-e_sum_max` @@ -1582,13 +1762,13 @@ Generator_e_sum_min: ```yaml Generator_e_sum_max: description: "`Generator-e_sum_max` — energy over the horizon is at most its budget; a budget of infinity is no row" - dims: [generator] + dims: [scenario, generator] where: Generator_e_sum_max expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) <= Generator_e_sum_max ``` ```math -\sum_{t \in \mathcal{T}} p_{t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \le \overline{\mathrm{E}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \overline{\mathrm{E}}_{g} \text{ is defined} +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \le \overline{\mathrm{E}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \overline{\mathrm{E}}_{g} \text{ is defined} ``` ### `Link-ext-p-lower` @@ -1598,13 +1778,13 @@ Generator_e_sum_max: ```yaml Link_ext_p_lower: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" - dims: [snapshot, link] - where: Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom_ext ``` ```math -f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p-upper` @@ -1614,13 +1794,13 @@ f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, t \in \mat ```yaml Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" - dims: [snapshot, link] - where: Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom_ext ``` ```math -f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p_nom-lower` @@ -1678,13 +1858,13 @@ F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{l} \qquad \forall\, l \in \mathcal{L} \,: ```yaml StorageUnit_fix_p_dispatch_lower: description: "`StorageUnit-fix-p_dispatch-lower` — dispatch is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch >= 0 ``` ```math -h^{+}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +h^{+}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-p_dispatch-upper` @@ -1694,13 +1874,13 @@ h^{+}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \ ```yaml StorageUnit_fix_p_dispatch_upper: description: "`StorageUnit-fix-p_dispatch-upper` — a fixed unit dispatches at most its nominal power" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom ``` ```math -h^{+}_{t,s} \le \overline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-p_store-lower` @@ -1710,13 +1890,13 @@ h^{+}_{t,s} \le \overline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} ```yaml StorageUnit_fix_p_store_lower: description: "`StorageUnit-fix-p_store-lower` — storing is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store >= 0 ``` ```math -h^{-}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +h^{-}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-p_store-upper` @@ -1728,13 +1908,13 @@ StorageUnit_fix_p_store_upper: description: >- `StorageUnit-fix-p_store-upper` — a fixed unit stores at most its nominal power, the minimum-per-unit column carrying that cap negated - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom ``` ```math -h^{-}_{t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-state_of_charge-lower` @@ -1744,13 +1924,13 @@ h^{-}_{t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s ```yaml StorageUnit_fix_state_of_charge_lower: description: "`StorageUnit-fix-state_of_charge-lower` — charge is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge >= 0 ``` ```math -\mathit{soc}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +\mathit{soc}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-state_of_charge-upper` @@ -1760,13 +1940,13 @@ StorageUnit_fix_state_of_charge_lower: ```yaml StorageUnit_fix_state_of_charge_upper: description: "`StorageUnit-fix-state_of_charge-upper` — a fixed unit holds at most its hours at nominal power" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom ``` ```math -\mathit{soc}_{t,s} \le \mathrm{T}^{h}_{s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `Generator-com-p-lower` @@ -1776,13 +1956,13 @@ StorageUnit_fix_state_of_charge_upper: ```yaml Generator_com_p_lower: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" - dims: [snapshot, generator] - where: Generator_committable AND not Generator_p_nom_extendable + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-p-upper` @@ -1792,13 +1972,13 @@ p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cd ```yaml Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" - dims: [snapshot, generator] - where: Generator_committable AND not Generator_p_nom_extendable + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-transition-start-up` @@ -1808,13 +1988,13 @@ p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdo ```yaml Generator_com_transition_start_up: description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active expression: Generator_start_up >= Generator_status - Generator_previous_status ``` ```math -\mathit{up}_{t,g} \ge u_{t,g} - \overleftarrow{u}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +\mathit{up}_{\xi,t,g} \ge u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-transition-shut-down` @@ -1824,13 +2004,13 @@ Generator_com_transition_start_up: ```yaml Generator_com_transition_shut_down: description: "`Generator-com-transition-shut-down` — turning off is a stop, counted against the state the unit carried into the snapshot" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active expression: Generator_shut_down >= Generator_previous_status - Generator_status ``` ```math -\mathit{dn}_{t,g} \ge \overleftarrow{u}_{t,g} - u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +\mathit{dn}_{\xi,t,g} \ge \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-up-time` @@ -1843,13 +2023,13 @@ Generator_com_up_time: `Generator-com-up-time` — a unit started within its own minimum up time is still on. The first snapshot's share of the window is the brought-in up time's, which the must-stay-up mask carries - dims: [snapshot, generator] - where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}} \mathit{up}_{t',g} \le u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{UT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}} \mathit{up}_{\xi,t',g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{UT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-down-time` @@ -1859,13 +2039,13 @@ Generator_com_up_time: ```yaml Generator_com_down_time: description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" - dims: [snapshot, generator] - where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}} \mathit{dn}_{t',g} \le 1 - u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{DT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}} \mathit{dn}_{\xi,t',g} \le 1 - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{DT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-status-min_up_time_must_stay_up` @@ -1875,13 +2055,13 @@ Generator_com_down_time: ```yaml Generator_com_status_must_stay_up: description: "`Generator-com-status-min_up_time_must_stay_up` — a unit still serving the up time it brought in stays on" - dims: [snapshot, generator] - where: Generator_committable AND Generator_must_stay_up + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_up AND Generator_active expression: Generator_status == 1 ``` ```math -u_{t,g} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{t,g} +u_{\xi,t,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{t,g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-run-bigM` @@ -1894,10 +2074,10 @@ Generator_p_ramp_limit_up_run_big_m: `Generator-p-ramp_limit_up-run-bigM` — a committed extendable unit raises output no faster than its limit of the chosen build; the big M releases the row in the snapshot it turns on - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_limit_up * Generator_p_nom_ext @@ -1905,7 +2085,7 @@ Generator_p_ramp_limit_up_run_big_m: ``` ```math -p_{t,g} - \overleftarrow{p}_{t,g} \le \mathrm{ru}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \mathrm{ru}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -1918,10 +2098,10 @@ Generator_p_ramp_limit_up_start_big_m: `Generator-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a committed extendable unit ramps no further than its start-up ramp of the chosen build; the big M releases the row everywhere else - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_limit_start_up * Generator_p_nom_ext @@ -1929,7 +2109,7 @@ Generator_p_ramp_limit_up_start_big_m: ``` ```math -p_{t,g} - \overleftarrow{p}_{t,g} \le \mathrm{ru}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \mathrm{ru}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -1942,10 +2122,10 @@ Generator_p_ramp_limit_down_run_big_m: `Generator-p-ramp_limit_down-run-bigM` — a committed extendable unit lowers output no faster than its limit of the chosen build; the big M releases the row in the snapshot it turns off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_limit_down * Generator_p_nom_ext @@ -1953,7 +2133,7 @@ Generator_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{p}_{t,g} - p_{t,g} \le \mathrm{rd}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \mathrm{rd}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -1966,10 +2146,10 @@ Generator_p_ramp_limit_down_shut_big_m: `Generator-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, a committed extendable unit ramps no further than its shut-down ramp of the chosen build; the big M releases the row everywhere else - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_limit_shut_down * Generator_p_nom_ext @@ -1977,7 +2157,7 @@ Generator_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{p}_{t,g} - p_{t,g} \le \mathrm{rd}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \mathrm{rd}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -2005,13 +2185,13 @@ Generator_com_ext_p_upper_cap: description: >- `Generator-com-ext-p-upper-cap` — a committed extendable unit outputs at most what is available of the chosen build, whatever its status - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-upper-bigM` @@ -2021,13 +2201,13 @@ p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \math ```yaml Generator_com_ext_p_upper_big_m: description: "`Generator-com-ext-p-upper-bigM` — off, a unit outputs nothing; on, the big M is no bound" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p <= Generator_big_m * Generator_status ``` ```math -p_{t,g} \le \mathrm{M}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +p_{\xi,t,g} \le \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-lower` @@ -2039,8 +2219,8 @@ Generator_com_ext_p_lower: description: >- `Generator-com-ext-p-lower` — a committed extendable unit outputs at least its minimum of the chosen build; off, the big M releases the row - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: >- Generator_p >= Generator_p_min_pu * Generator_p_nom_ext @@ -2048,7 +2228,7 @@ Generator_com_ext_p_lower: ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} + \mathrm{M}_{g} \cdot u_{t,g} - \mathrm{M}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} + \mathrm{M}_{g} \cdot u_{\xi,t,g} - \mathrm{M}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-lower-nonneg` @@ -2061,15 +2241,15 @@ Generator_com_ext_p_lower_nonneg: `Generator-com-ext-p-lower-nonneg` — where no minimum-per-unit is negative, output is also plainly non-negative, a row the big-M lower cannot assert while the unit is off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable - AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) + AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p >= 0 ``` ```math -p_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{nonneg}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +p_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{nonneg}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-mod-p-lower` @@ -2081,13 +2261,13 @@ Generator_com_mod_p_lower: description: >- `Generator-com-mod-p-lower` — a committed modular unit outputs at least its minimum of one module, whether the build is fixed or a decision - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * Generator_status ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-mod-p-upper` @@ -2099,13 +2279,13 @@ Generator_com_mod_p_upper: description: >- `Generator-com-mod-p-upper` — a committed modular unit outputs at most one module's share, whether the build is fixed or a decision - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * Generator_status ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-status-p-fixed-upper` @@ -2118,13 +2298,13 @@ Generator_status_p_fixed_upper: `Generator-status-p-fixed-upper` — a status is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_status <= Generator_modules_installed ``` ```math -u_{t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +u_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-start_up-p-fixed-upper` @@ -2137,13 +2317,13 @@ Generator_start_up_p_fixed_upper: `Generator-start_up-p-fixed-upper` — a start is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_start_up <= Generator_modules_installed ``` ```math -\mathit{up}_{t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +\mathit{up}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-shut_down-p-fixed-upper` @@ -2156,13 +2336,13 @@ Generator_shut_down_p_fixed_upper: `Generator-shut_down-p-fixed-upper` — a stop is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_shut_down <= Generator_modules_installed ``` ```math -\mathit{dn}_{t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) +\mathit{dn}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-status-p_nom-variable-upper` @@ -2172,13 +2352,13 @@ Generator_shut_down_p_fixed_upper: ```yaml Generator_status_p_nom_variable_upper: description: "`Generator-status-p_nom-variable-upper` — a modular unit is on only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_status <= Generator_n_mod ``` ```math -u_{t,g} \le N_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +u_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-start_up-p_nom-variable-upper` @@ -2188,13 +2368,13 @@ u_{t,g} \le N_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \ ```yaml Generator_start_up_p_nom_variable_upper: description: "`Generator-start_up-p_nom-variable-upper` — a modular unit starts only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_start_up <= Generator_n_mod ``` ```math -\mathit{up}_{t,g} \le N_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +\mathit{up}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-shut_down-p_nom-variable-upper` @@ -2204,13 +2384,13 @@ Generator_start_up_p_nom_variable_upper: ```yaml Generator_shut_down_p_nom_variable_upper: description: "`Generator-shut_down-p_nom-variable-upper` — a modular unit stops only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_shut_down <= Generator_n_mod ``` ```math -\mathit{dn}_{t,g} \le N_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +\mathit{dn}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Line-fix-s-lower` @@ -2220,13 +2400,13 @@ Generator_shut_down_p_nom_variable_upper: ```yaml Line_fix_s_lower: description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating" - dims: [snapshot, line] - where: not Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active expression: Line_s >= -Line_s_max_pu * Line_s_nom ``` ```math -s_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} +s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-fix-s-upper` @@ -2236,13 +2416,13 @@ s_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qq ```yaml Line_fix_s_upper: description: "`Line-fix-s-upper` — a fixed line carries at most its rating" - dims: [snapshot, line] - where: not Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active expression: Line_s <= Line_s_max_pu * Line_s_nom ``` ```math -s_{t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} +s_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-lower` @@ -2252,13 +2432,13 @@ s_{t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qqu ```yaml Line_ext_s_lower: description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active expression: Line_s >= -Line_s_max_pu * Line_s_nom_ext ``` ```math -s_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} +s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-upper` @@ -2268,13 +2448,13 @@ s_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, t \in \mat ```yaml Line_ext_s_upper: description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active expression: Line_s <= Line_s_max_pu * Line_s_nom_ext ``` ```math -s_{t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} +s_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s_nom-lower` @@ -2332,13 +2512,13 @@ S_{k} = \mathrm{s}^{\mathrm{nom,set}}_{k} \qquad \forall\, k \in \mathcal{K} \,: ```yaml Line_s_set: description: "`Line-s_set` — flow pinned to the given schedule, wherever one is given" - dims: [snapshot, line] - where: Line_s_set + dims: [scenario, snapshot, line] + where: Line_s_set AND Line_active expression: Line_s == Line_s_set ``` ```math -s_{t,k} = \mathrm{s}^{\mathrm{set}}_{t,k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{t,k} \text{ is defined} +s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Kirchhoff-Voltage-Law` @@ -2351,12 +2531,12 @@ Kirchhoff_Voltage_Law: `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear power flow physical rather than transport - dims: [snapshot, cycle] + dims: [scenario, snapshot, cycle] expression: sum(Line_s * Line_cycle_weight, over=line) == 0 ``` ```math -\sum_{k \in \mathcal{K}} s_{t,k} \cdot \mathrm{x}_{k,c} = 0 \qquad \forall\, t \in \mathcal{T},\ c \in \mathcal{C} +\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` ### `Generator-p-ramp_limit_up` @@ -2371,16 +2551,16 @@ Generator_p_ramp_limit_up: start-up ramp in the snapshot it turns on. A unit that came into the horizon running brought an unknown output, so it carries no row at the first snapshot — nor does any unit a big M releases instead - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_ramp_limit_up AND NOT (Generator_committable AND Generator_p_nom_extendable) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) + AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance ``` ```math -p_{t,g} - \overleftarrow{p}_{t,g} \le \Delta^{+}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ru}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ru}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -2395,16 +2575,16 @@ Generator_p_ramp_limit_down: shut-down ramp in the snapshot it turns off. A unit that came into the horizon running brought an unknown output, so it carries no row at the first snapshot — nor does any unit a big M releases instead - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_ramp_limit_down AND NOT (Generator_committable AND Generator_p_nom_extendable) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) + AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance ``` ```math -\overleftarrow{p}_{t,g} - p_{t,g} \le \Delta^{-}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{rd}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{rd}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -2417,13 +2597,13 @@ Link_p_ramp_limit_up: `Link-p-ramp_limit_up` — a link raises flow no faster than its limit of the build. The translated term vacates the first snapshot, where a plain optimize builds no row either - dims: [snapshot, link] - where: Link_ramp_limit_up + dims: [scenario, snapshot, link] + where: Link_ramp_limit_up AND Link_active expression: Link_p - shift(Link_p, along=snapshot, offset=1) <= Link_ramp_limit_up * Link_p_nom_effective ``` ```math -f_{t,l} - f_{t - 1,l} \le \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ru}^{f}_{l} \text{ is defined} +f_{\xi,t,l} - f_{\xi,t - 1,l} \le \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -2433,13 +2613,13 @@ f_{t,l} - f_{t - 1,l} \le \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mat ```yaml Link_p_ramp_limit_down: description: "`Link-p-ramp_limit_down` — a link lowers flow no faster than its limit of the build" - dims: [snapshot, link] - where: Link_ramp_limit_down + dims: [scenario, snapshot, link] + where: Link_ramp_limit_down AND Link_active expression: shift(Link_p, along=snapshot, offset=1) - Link_p <= Link_ramp_limit_down * Link_p_nom_effective ``` ```math -f_{t - 1,l} - f_{t,l} \le \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} +f_{\xi,t - 1,l} - f_{\xi,t,l} \le \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -2449,13 +2629,13 @@ f_{t - 1,l} - f_{t,l} \le \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mat ```yaml StorageUnit_ext_p_dispatch_lower: description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch >= 0 ``` ```math -h^{+}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +h^{+}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_dispatch-upper` @@ -2465,13 +2645,13 @@ h^{+}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \ ```yaml StorageUnit_ext_p_dispatch_upper: description: "`StorageUnit-ext-p_dispatch-upper` — an extendable unit dispatches at most the chosen build" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom_ext ``` ```math -h^{+}_{t,s} \le \overline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_store-lower` @@ -2481,13 +2661,13 @@ h^{+}_{t,s} \le \overline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, t \in \ ```yaml StorageUnit_ext_p_store_lower: description: "`StorageUnit-ext-p_store-lower` — storing is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store >= 0 ``` ```math -h^{-}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +h^{-}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_store-upper` @@ -2499,13 +2679,13 @@ StorageUnit_ext_p_store_upper: description: >- `StorageUnit-ext-p_store-upper` — an extendable unit stores at most the chosen build, the minimum-per-unit column carrying that cap negated - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom_ext ``` ```math -h^{-}_{t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-state_of_charge-lower` @@ -2515,13 +2695,13 @@ h^{-}_{t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, t \in ```yaml StorageUnit_ext_state_of_charge_lower: description: "`StorageUnit-ext-state_of_charge-lower` — charge is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge >= 0 ``` ```math -\mathit{soc}_{t,s} \ge 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +\mathit{soc}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-state_of_charge-upper` @@ -2531,13 +2711,13 @@ StorageUnit_ext_state_of_charge_lower: ```yaml StorageUnit_ext_state_of_charge_upper: description: "`StorageUnit-ext-state_of_charge-upper` — an extendable unit holds at most its hours at the chosen build" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom_ext ``` ```math -\mathit{soc}_{t,s} \le \mathrm{T}^{h}_{s} \cdot H_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_nom-lower` @@ -2598,7 +2778,8 @@ StorageUnit_energy_balance: `StorageUnit-energy_balance` — the charge carried in, plus what is stored after its efficiency, less what dispatch draws down before its own, plus inflow not spilled - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active expression: >- StorageUnit_state_of_charge == StorageUnit_charge_carried_in @@ -2608,7 +2789,7 @@ StorageUnit_energy_balance: ``` ```math -\mathit{soc}_{t,s} = \overleftarrow{\mathit{soc}}_{t,s} + \eta^{-}_{s} \cdot h^{-}_{t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{s}} + \left( \mathrm{inflow}_{t,s} - \mathit{spill}_{t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} +\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{s}} + \left( \mathrm{inflow}_{t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` ### `Store-fix-e-lower` @@ -2618,13 +2799,13 @@ StorageUnit_energy_balance: ```yaml Store_fix_e_lower: description: "`Store-fix-e-lower` — a fixed store holds at least its floor" - dims: [snapshot, store] - where: not Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active expression: Store_e >= Store_e_min_pu * Store_e_nom ``` ```math -e_{t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-fix-e-upper` @@ -2634,13 +2815,13 @@ e_{t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qq ```yaml Store_fix_e_upper: description: "`Store-fix-e-upper` — a fixed store holds at most its nominal capacity" - dims: [snapshot, store] - where: not Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active expression: Store_e <= Store_e_max_pu * Store_e_nom ``` ```math -e_{t,v} \le \overline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} +e_{\xi,t,v} \le \overline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e-lower` @@ -2650,13 +2831,13 @@ e_{t,v} \le \overline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qqu ```yaml Store_ext_e_lower: description: "`Store-ext-e-lower` — an extendable store holds at least its floor of the chosen build" - dims: [snapshot, store] - where: Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active expression: Store_e >= Store_e_min_pu * Store_e_nom_ext ``` ```math -e_{t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e-upper` @@ -2666,13 +2847,13 @@ e_{t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, t \in \mat ```yaml Store_ext_e_upper: description: "`Store-ext-e-upper` — an extendable store holds at most the chosen build" - dims: [snapshot, store] - where: Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active expression: Store_e <= Store_e_max_pu * Store_e_nom_ext ``` ```math -e_{t,v} \le \overline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} +e_{\xi,t,v} \le \overline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e_nom-lower` @@ -2730,7 +2911,8 @@ E_{v} = \mathrm{e}^{\mathrm{nom,set}}_{v} \qquad \forall\, v \in \mathcal{V} \,: ```yaml Store_energy_balance: description: "`Store-energy_balance` — the energy carried in, less what is delivered to the bus" - dims: [snapshot, store] + dims: [scenario, snapshot, store] + where: Store_active expression: >- Store_e == Store_energy_carried_in @@ -2738,7 +2920,7 @@ Store_energy_balance: ``` ```math -e_{t,v} = \overleftarrow{e}_{t,v} - q_{t,v} \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} +e_{\xi,t,v} = \overleftarrow{e}_{\xi,t,v} - q_{\xi,t,v} \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} ``` ### `Generator-p_set` @@ -2748,13 +2930,13 @@ e_{t,v} = \overleftarrow{e}_{t,v} - q_{t,v} \cdot \mathrm{w}^{\mathrm{sto}}_{t} ```yaml Generator_p_set: description: "`Generator-p_set` — output pinned to the given schedule, wherever one is given" - dims: [snapshot, generator] - where: Generator_p_set + dims: [scenario, snapshot, generator] + where: Generator_p_set AND Generator_active expression: Generator_p == Generator_p_set ``` ```math -p_{t,g} = \mathrm{p}^{\mathrm{set}}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{set}}_{t,g} \text{ is defined} +p_{\xi,t,g} = \mathrm{p}^{\mathrm{set}}_{t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{set}}_{t,g} \text{ is defined} \wedge \mathrm{on}_{t,g} ``` ### `Link-p_set` @@ -2764,13 +2946,13 @@ p_{t,g} = \mathrm{p}^{\mathrm{set}}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g ```yaml Link_p_set: description: "`Link-p_set` — flow pinned to the given schedule, wherever one is given" - dims: [snapshot, link] - where: Link_p_set + dims: [scenario, snapshot, link] + where: Link_p_set AND Link_active expression: Link_p == Link_p_set ``` ```math -f_{t,l} = \mathrm{f}^{\mathrm{set}}_{t,l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{t,l} \text{ is defined} +f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{t,l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` ### `StorageUnit-p_set` @@ -2780,13 +2962,13 @@ f_{t,l} = \mathrm{f}^{\mathrm{set}}_{t,l} \qquad \forall\, t \in \mathcal{T},\ l ```yaml StorageUnit_p_set: description: "`StorageUnit-p_set` — net dispatch pinned to the given schedule, wherever one is given" - dims: [snapshot, storage_unit] - where: StorageUnit_p_set + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_set AND StorageUnit_active expression: StorageUnit_p_dispatch - StorageUnit_p_store == StorageUnit_p_set ``` ```math -h^{+}_{t,s} - h^{-}_{t,s} = \mathrm{h}^{\mathrm{set}}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{t,s} \text{ is defined} +h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} = \mathrm{h}^{\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-state_of_charge_set` @@ -2796,13 +2978,13 @@ h^{+}_{t,s} - h^{-}_{t,s} = \mathrm{h}^{\mathrm{set}}_{t,s} \qquad \forall\, t \ ```yaml StorageUnit_state_of_charge_set: description: "`StorageUnit-state_of_charge_set` — charge pinned to the given schedule, wherever one is given" - dims: [snapshot, storage_unit] - where: StorageUnit_state_of_charge_set + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_state_of_charge_set AND StorageUnit_active expression: StorageUnit_state_of_charge == StorageUnit_state_of_charge_set ``` ```math -\mathit{soc}_{t,s} = \mathrm{soc}^{\mathrm{set}}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{soc}^{\mathrm{set}}_{t,s} \text{ is defined} +\mathit{soc}_{\xi,t,s} = \mathrm{soc}^{\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{soc}^{\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `Store-e_set` @@ -2812,13 +2994,13 @@ StorageUnit_state_of_charge_set: ```yaml Store_e_set: description: "`Store-e_set` — energy pinned to the given schedule, wherever one is given" - dims: [snapshot, store] - where: Store_e_set + dims: [scenario, snapshot, store] + where: Store_e_set AND Store_active expression: Store_e == Store_e_set ``` ```math -e_{t,v} = \mathrm{e}^{\mathrm{set}}_{t,v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{t,v} \text{ is defined} +e_{\xi,t,v} = \mathrm{e}^{\mathrm{set}}_{t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} ``` ### `primary_energy` @@ -2828,13 +3010,13 @@ e_{t,v} = \mathrm{e}^{\mathrm{set}}_{t,v} \qquad \forall\, t \in \mathcal{T},\ v ```yaml GlobalConstraint_primary_energy_ub: description: "`primary_energy` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '<=' expression: primary_energy <= GlobalConstraint_constant ``` ```math -\mathit{primary\_energy}_{b} \le \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{primary\_energy}_{\xi,b} \le \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} ``` ### `primary_energy` @@ -2844,13 +3026,13 @@ GlobalConstraint_primary_energy_ub: ```yaml GlobalConstraint_primary_energy_lb: description: "`primary_energy` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '>=' expression: primary_energy >= GlobalConstraint_constant ``` ```math -\mathit{primary\_energy}_{b} \ge \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{primary\_energy}_{\xi,b} \ge \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} ``` ### `primary_energy` @@ -2860,13 +3042,13 @@ GlobalConstraint_primary_energy_lb: ```yaml GlobalConstraint_primary_energy_eq: description: "`primary_energy` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '==' expression: primary_energy == GlobalConstraint_constant ``` ```math -\mathit{primary\_energy}_{b} = \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{primary\_energy}_{\xi,b} = \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} ``` ### `operational_limit` @@ -2876,13 +3058,13 @@ GlobalConstraint_primary_energy_eq: ```yaml GlobalConstraint_operational_limit_ub: description: "`operational_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '<=' expression: operational_limit <= GlobalConstraint_constant ``` ```math -\mathit{operational\_limit}_{b} \le \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{operational\_limit}_{\xi,b} \le \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} ``` ### `operational_limit` @@ -2892,13 +3074,13 @@ GlobalConstraint_operational_limit_ub: ```yaml GlobalConstraint_operational_limit_lb: description: "`operational_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '>=' expression: operational_limit >= GlobalConstraint_constant ``` ```math -\mathit{operational\_limit}_{b} \ge \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{operational\_limit}_{\xi,b} \ge \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} ``` ### `operational_limit` @@ -2908,13 +3090,13 @@ GlobalConstraint_operational_limit_lb: ```yaml GlobalConstraint_operational_limit_eq: description: "`operational_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '==' expression: operational_limit == GlobalConstraint_constant ``` ```math -\mathit{operational\_limit}_{b} = \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{operational\_limit}_{\xi,b} = \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -3074,7 +3256,7 @@ Bus_nodal_balance: load there. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. - dims: [snapshot, bus] + dims: [scenario, snapshot, bus] expression: >- sum(Generator_p, by=Generator_bus, over=generator, into=bus) + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus, over=storage_unit, into=bus) @@ -3087,7 +3269,60 @@ Bus_nodal_balance: ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{t,s} - h^{-}_{t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{t,o} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{t,k} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Carrier-growth_limit` + +`Carrier_growth_limit` + +```yaml +Carrier_growth_limit: + description: >- + `Carrier-growth_limit` — what a carrier adds in a period, counting each build in the first + period it stands in, is at most its allowance plus a share of what it added the period before; + the first period has no predecessor, so `edge=0` leaves it the bare allowance + dims: [carrier, period] + where: Carrier_max_growth + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) + * Carrier_max_relative_growth + <= Carrier_max_growth +``` + +```math +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} - \left( \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y \boxminus_{0} 1,g} \right) \cdot \mathrm{r}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} +``` + +### `CVaR-excess-{s}` + +`CVaR_excess` + +```yaml +CVaR_excess: + description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" + dims: [scenario] + expression: CVaR_a - scenario_opex + CVaR_theta >= 0 +``` + +```math +a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in \Xi +``` + +### `CVaR-def` + +`CVaR_def` + +```yaml +CVaR_def: + description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" + dims: [] + expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR +``` + +```math +\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR ``` ### `Generator_previous_status` @@ -3097,14 +3332,14 @@ Generator_previous_status: description: >- the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: opening: { when: "position(snapshot) == 0", expression: Generator_status_initial } otherwise: shift(Generator_status, along=snapshot, offset=1) ``` ```math -\overleftarrow{u}_{t,g} = \begin{cases} \mathrm{u}^{0}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ u_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\overleftarrow{u}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ u_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_previous_p` @@ -3115,14 +3350,14 @@ Generator_previous_p: the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: opening: { when: "position(snapshot) == 0", expression: 0 } otherwise: shift(Generator_p, along=snapshot, offset=1) ``` ```math -\overleftarrow{p}_{t,g} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ p_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\overleftarrow{p}_{\xi,t,g} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ p_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_p_nom_effective` @@ -3148,7 +3383,7 @@ Generator_ramp_up_allowance: how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: committed: when: Generator_committable @@ -3160,7 +3395,7 @@ Generator_ramp_up_allowance: ``` ```math -\Delta^{+}_{t,g} = \begin{cases} \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{t,g} + \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \overleftarrow{u}_{t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{ru}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{+}_{\xi,t,g} = \begin{cases} \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{\xi,t,g} + \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{ru}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_allowance` @@ -3171,7 +3406,7 @@ Generator_ramp_down_allowance: how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: committed: when: Generator_committable @@ -3183,7 +3418,7 @@ Generator_ramp_down_allowance: ``` ```math -\Delta^{-}_{t,g} = \begin{cases} \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{t,g} - u_{t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{rd}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{-}_{\xi,t,g} = \begin{cases} \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} + \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{rd}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Link_p_nom_effective` @@ -3210,7 +3445,7 @@ StorageUnit_charge_carried_in: standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] cases: cyclic: when: StorageUnit_cyclic_state_of_charge @@ -3222,7 +3457,7 @@ StorageUnit_charge_carried_in: ``` ```math -\overleftarrow{\mathit{soc}}_{t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{t \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \mathrm{pos}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} +\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \mathrm{pos}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} ``` ### `Store_energy_carried_in` @@ -3234,7 +3469,7 @@ Store_energy_carried_in: standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise - dims: [snapshot, store] + dims: [scenario, snapshot, store] cases: cyclic: when: Store_e_cyclic @@ -3246,7 +3481,7 @@ Store_energy_carried_in: ``` ```math -\overleftarrow{e}_{t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{t \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \mathrm{pos}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} +\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,t \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \mathrm{pos}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} ``` ### `Link_output_arrival` @@ -3260,7 +3495,7 @@ Link_output_arrival: is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not - dims: [snapshot, link_output] + dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay @@ -3269,7 +3504,7 @@ Link_output_arrival: ``` ```math -\overrightarrow{f}_{t,o} = \begin{cases} f_{t \ominus \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{t \boxminus_{0} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ o \in \mathcal{O} +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` ### `primary_energy` @@ -3287,7 +3522,7 @@ primary_energy: ``` ```math -\mathit{primary\_energy}_{b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{a}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{e}_{b,v} \right) \qquad \forall\, b \in \mathcal{B} +\mathit{primary\_energy}_{\xi,b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{a}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{e}_{b,v} \right) \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} ``` ### `operational_limit` @@ -3305,7 +3540,7 @@ operational_limit: ``` ```math -\mathit{operational\_limit}_{b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{b}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{e}_{b,v} \right) \qquad \forall\, b \in \mathcal{B} +\mathit{operational\_limit}_{\xi,b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{b}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{e}_{b,v} \right) \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} ``` ### `transmission_volume_expansion` @@ -3353,54 +3588,76 @@ tech_capacity_expansion: \mathit{tech\_capacity\_expansion}_{b} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{b,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{b,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{b,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{b,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{b,v} \qquad \forall\, b \in \mathcal{B} ``` +### `scenario_opex` + +```yaml +scenario_opex: + description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight + expression: >- + sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) +``` + +```math +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + #### Variable domains **`Generator_p`** ```math -p_{t,g} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +p_{\xi,t,g} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{on}_{t,g} ``` **`Link_p`** ```math -f_{t,l} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} +f_{\xi,t,l} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{on}^{f}_{t,l} ``` **`StorageUnit_p_dispatch`** ```math -h^{+}_{t,s} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} +h^{+}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` **`StorageUnit_p_store`** ```math -h^{-}_{t,s} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} +h^{-}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` **`StorageUnit_state_of_charge`** ```math -\mathit{soc}_{t,s} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} +\mathit{soc}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` **`StorageUnit_spill`** ```math -0 \le \mathit{spill}_{t,s} \le \mathrm{inflow}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{inflow}_{t,s} > 0 +0 \le \mathit{spill}_{\xi,t,s} \le \mathrm{inflow}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{inflow}_{t,s} > 0 \wedge \mathrm{on}^{h}_{t,s} ``` **`Store_e`** ```math -e_{t,v} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} +e_{\xi,t,v} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} ``` **`Store_p`** ```math -q_{t,v} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} +q_{\xi,t,v} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} ``` **`Generator_n_mod`** @@ -3412,25 +3669,25 @@ N_{g} \ge 0, N_{g} \in \mathbb{Z} \qquad \forall\, g \in \mathcal{G} \,:\, \math **`Generator_status`** ```math -u_{t,g} \ge 0, u_{t,g} \in \mathbb{Z} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` **`Generator_start_up`** ```math -\mathit{up}_{t,g} \ge 0, \mathit{up}_{t,g} \in \mathbb{Z} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +\mathit{up}_{\xi,t,g} \ge 0, \mathit{up}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` **`Generator_shut_down`** ```math -\mathit{dn}_{t,g} \ge 0, \mathit{dn}_{t,g} \in \mathbb{Z} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +\mathit{dn}_{\xi,t,g} \ge 0, \mathit{dn}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` **`Line_s`** ```math -s_{t,k} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} +s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{on}^{s}_{t,k} ``` **`Line_s_nom_ext`** @@ -3462,6 +3719,24 @@ H_{s} \in \mathbb{R} \qquad \forall\, s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{ ```math E_{v} \in \mathbb{R} \qquad \forall\, v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} ``` + +**`CVaR_a`** + +```math +a_{\xi} \ge 0 \qquad \forall\, \xi \in \Xi +``` + +**`CVaR_theta`** + +```math +\theta \in \mathbb{R} +``` + +**`CVaR`** + +```math +CVaR \in \mathbb{R} +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/pypsa_multi_period.md b/docs/examples/pypsa_multi_period.md deleted file mode 100644 index b2fa3c07..00000000 --- a/docs/examples/pypsa_multi_period.md +++ /dev/null @@ -1,372 +0,0 @@ - - -# PyPSA, the multi-period class - -Rung 15 of [PyPSA in one file](pypsa.md): `n.optimize(multi_investment_periods=True)`, stated on rungs 1 and 3 in a -file of its own. Its network is a whole one: eight snapshots over two investment periods, build years and lifetimes on the script. - -## Rung 15 — investment periods, with a growth limit - -| PyPSA | status | note | -| --- | --- | --- | -| [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | -| [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | -| [`Carrier-growth_limit`](#carrier-growth_limit) | done | counted in the first period a build stands in; `edge=0` at the first period | -| [objective](#objective) | done | period weight on operation; capacity once per period it stands in | -| `StorageUnit-energy_balance` per period, ramps at period starts | out | `shift(…, by=snapshot_period, within=period)` has them; a later rung | - - -> ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. - -
-The network, as PyPSA code - -`rung_15_multi_period.py` - -```python -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -"""Rung 15: two investment periods — build years, lifetimes, period weights and a carrier's growth limit, stated by `pypsa_multi_period.yaml`.""" - -from __future__ import annotations - -from datetime import datetime - -import pandas as pd - -MODEL = 'pypsa_multi_period.yaml' -OPTIMIZE = {'multi_investment_periods': True} - - -def build(): - """A whole network, not the spine: eight snapshots over two periods, a unit that retires, two wind builds capped by growth.""" - import pypsa - - n = pypsa.Network() - n.snapshots = pd.MultiIndex.from_tuples( - [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] - ) - n.investment_periods = [2020, 2030] - n.investment_period_weightings['objective'] = [1.0, 0.5] - n.investment_period_weightings['years'] = [10.0, 10.0] - n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] - n.add('Bus', ['north', 'south']) - n.add('Carrier', 'wind', max_growth=50, max_relative_growth=0.5) - n.add('Carrier', 'gas') - n.add('Generator', 'old_gas', bus='north', carrier='gas', p_nom=40, marginal_cost=30, build_year=2010, lifetime=15) - n.add( - 'Generator', - 'wind20', - bus='north', - carrier='wind', - p_nom_extendable=True, - p_nom_max=200, - marginal_cost=1, - capital_cost=100, - build_year=2020, - lifetime=30, - p_max_pu=[0.8, 0.6, 0.7, 0.5, 0.8, 0.6, 0.7, 0.5], - ) - n.add( - 'Generator', - 'wind30', - bus='south', - carrier='wind', - p_nom_extendable=True, - p_nom_max=200, - marginal_cost=1, - capital_cost=80, - build_year=2030, - lifetime=30, - p_max_pu=[0.9, 0.7, 0.6, 0.8, 0.9, 0.7, 0.6, 0.8], - ) - n.add( - 'Generator', - 'gas30', - bus='south', - carrier='gas', - p_nom_extendable=True, - p_nom_max=200, - marginal_cost=40, - capital_cost=50, - build_year=2030, - lifetime=30, - ) - n.add('Link', 'wire15', bus0='north', bus1='south', p_nom=60, p_min_pu=-1, efficiency=0.95) - n.add('Load', 'town15', bus='north', p_set=[20, 30, 25, 20, 35, 45, 40, 30]) - n.add('Load', 'port15', bus='south', p_set=[10, 20, 15, 10, 30, 40, 35, 25]) - return n -``` - -
- - -## The file - - -The multi-period class of a plain `n.optimize()`: `multi_investment_periods`, stated on rungs 1 and 3 in a file of its own. A snapshot belongs to an investment period, an asset stands in the periods its build year and lifetime span, and capacity is paid once per period it stands in, each period weighted; a carrier may grow only so much per period. Which snapshots an asset is active in is data prep, because a `where` reaches only the frame's own dimensions. A dimension a run may not have cannot ride on `examples/pypsa.yaml`, so this class lives here. - -#### Sets - -| Symbol | Meaning | -|---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods, positions across every investment period | -| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | -| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{C},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | -| $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | -| $`\mathcal{C}`$ | index $`c`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{C}`$ — energy carriers, what a growth limit is set per | - -#### Parameters - -| Symbol | Meaning | -|---|---| -| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | -| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | -| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | -| $`\mathrm{W}`$ | `Generator_capital_weight` over $`\mathcal{G}`$ — the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep | -| $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{C}`$ — most capacity of a carrier that may be added in a period; no value means no limit | -| $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{C}`$ — share of the previous period's additions that may be added on top | -| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | -| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$ — whether the nominal power is a decision | -| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\mathcal{G}`$ — least nominal power an extendable generator may be built at | -| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\mathcal{G}`$ — most nominal power an extendable generator may be built at | -| $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\mathcal{G}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | -| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | -| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | -| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | -| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | -| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | - -#### Variables - -| Symbol | Meaning | -|---|---| -| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | -| $`f`$ | `Link_p` over $`\mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | -| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | - -$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. - -### Objective - -```yaml -objective: - sense: minimize - description: operating cost by weighted snapshot and weighted period, and capacity once per period it stands in - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot)) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot)) - + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) -``` - -```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T},\ l \in \mathcal{L}} f_{t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} -``` - -### `Generator-fix-p-lower` - -`Generator_fix_p_lower` - -```yaml -Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom -``` - -```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} -``` - -### `Generator-fix-p-upper` - -`Generator_fix_p_upper` - -```yaml -Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom -``` - -```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} -``` - -### `Generator-ext-p-lower` - -`Generator_ext_p_lower` - -```yaml -Generator_ext_p_lower: - description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext -``` - -```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} -``` - -### `Generator-ext-p-upper` - -`Generator_ext_p_upper` - -```yaml -Generator_ext_p_upper: - description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext -``` - -```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} -``` - -### `Generator-ext-p_nom-lower` - -`Generator_ext_p_nom_lower` - -```yaml -Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] - where: Generator_p_nom_extendable - expression: Generator_p_nom_ext >= Generator_p_nom_min -``` - -```math -P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - -### `Generator-ext-p_nom-upper` - -`Generator_ext_p_nom_upper` - -```yaml -Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] - where: Generator_p_nom_extendable AND Generator_p_nom_max - expression: Generator_p_nom_ext <= Generator_p_nom_max -``` - -```math -P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \text{ is defined} -``` - -### `Link-fix-p-lower` - -`Link_fix_p_lower` - -```yaml -Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom -``` - -```math -f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Link-fix-p-upper` - -`Link_fix_p_upper` - -```yaml -Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom -``` - -```math -f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Bus-nodal_balance` - -`Bus_nodal_balance` - -```yaml -Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) -``` - -```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} -``` - -### `Carrier-growth_limit` - -`Carrier_growth_limit` - -```yaml -Carrier_growth_limit: - description: >- - `Carrier-growth_limit` — what a carrier adds in a period, counting each - build in the first period it stands in, is at most its allowance plus a - share of what it added the period before; the first period has no - predecessor, so `edge=0` leaves it the bare allowance - dims: [carrier, period] - where: Carrier_max_growth - expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) - * Carrier_max_relative_growth - <= Carrier_max_growth -``` - -```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = c} P_{g} \cdot \mathrm{new}_{y,g} - \left( \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = c} P_{g} \cdot \mathrm{new}_{y \boxminus_{0} 1,g} \right) \cdot \mathrm{r}_{c} \le \overline{\Delta}_{c} \qquad \forall\, c \in \mathcal{C},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{c} \text{ is defined} -``` - -#### Variable domains - -**`Generator_p`** - -```math -p_{t,g} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{on}_{t,g} -``` - -**`Link_p`** - -```math -f_{t,l} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -**`Generator_p_nom_ext`** - -```math -P_{g} \in \mathbb{R} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - - -Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/pypsa_stochastic.md b/docs/examples/pypsa_stochastic.md deleted file mode 100644 index c631c8d4..00000000 --- a/docs/examples/pypsa_stochastic.md +++ /dev/null @@ -1,365 +0,0 @@ - - -# PyPSA, the two-stage class - -Rung 14 of [PyPSA in one file](pypsa.md): `n.set_scenarios(...)` with `n.set_risk_preference(alpha, omega)`, stated on rungs 1 and 3 in a -file of its own. Its network is the spine plus the script's own additions. - -## Rung 14 — two-stage stochastic, with CVaR - -| PyPSA | status | note | -| --- | --- | --- | -| [`Generator-p`, `Link-p`](#variable-domains) | done | over `scenario`; `Generator-p_nom` is not — chosen once | -| [`Generator-fix-p-*`, `-ext-p-*`, `Link-fix-p-*`, `Bus-nodal_balance`](#generator-fix-p-lower) | done | rungs 1 and 3, over `scenario` | -| [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | -| [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | -| [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | -| [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | - - -> ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. - -
-The network, as PyPSA code - -`rung_14_stochastic.py` - -```python -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -"""Rung 14: two futures and a risk preference — capacity chosen once, dispatch per scenario, stated by `pypsa_stochastic.yaml`.""" - -from __future__ import annotations - -import spine - -MODEL = 'pypsa_stochastic.yaml' - - -def build(): - """The spine plus an extendable wind unit whose availability and the south's load differ between a calm and a stormy future.""" - n = spine.build() - n.add('Generator', 'wind14', bus='south', p_nom_extendable=True, p_nom_max=100, marginal_cost=1, capital_cost=20) - n.add('Load', 'port14', bus='south') - n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) - n.c.loads.dynamic.p_set[('calm', 'port14')] = [10, 20, 15, 10] - n.c.loads.dynamic.p_set[('stormy', 'port14')] = [40, 60, 50, 30] - n.c.generators.dynamic.p_max_pu[('calm', 'wind14')] = [0.9, 0.7, 0.8, 0.6] - n.c.generators.dynamic.p_max_pu[('stormy', 'wind14')] = [0.3, 0.2, 0.4, 0.1] - n.set_risk_preference(alpha=0.5, omega=0.3) - return n -``` - -
- - -## The file - - -The two-stage class of a plain `n.optimize()`: a network with scenarios, stated on rung 1's transport and rung 3's expansion in a file of its own. Everything over a snapshot spans a scenario as well; capacity does not — it is chosen once, before the future is known — and the cost is the expectation over the scenarios' weights. With a risk preference PyPSA adds the CVaR rows: an excess per scenario and the tail's average, blended into the objective. A dimension a run may not have cannot ride on `examples/pypsa.yaml`, so this class lives here. - -#### Sets - -| Symbol | Meaning | -|---|---| -| $`\mathcal{S}`$ | index $`s`$ — `scenario` — the futures dispatch is chosen in, each with a weight | -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | -| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | -| $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | - -#### Parameters - -| Symbol | Meaning | -|---|---| -| $`\pi`$ | `scenario_weight` over $`\mathcal{S}`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | -| $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of the operating cost priced at the tail rather than in expectation | -| $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | -| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | -| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | -| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$ — whether the nominal power is a decision | -| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\mathcal{G}`$ — least nominal power an extendable generator may be built at | -| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\mathcal{G}`$ — most nominal power an extendable generator may be built at | -| $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\mathcal{G}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | -| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{S} \times \mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | -| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | -| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | -| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | -| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{S} \times \mathcal{T} \times \mathcal{D}`$ — demand | - -#### Variables - -| Symbol | Meaning | -|---|---| -| $`p`$ | `Generator_p` over $`\mathcal{S} \times \mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | -| $`f`$ | `Link_p` over $`\mathcal{S} \times \mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | -| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | -| $`a`$ | `CVaR_a` over $`\mathcal{S}`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | -| $`\theta`$ | `CVaR_theta` (scalar) — `CVaR-theta` — where the tail starts, the value at risk | -| $`CVaR`$ | `CVaR` (scalar) — `CVaR` — the tail's average cost, what the objective prices at `omega` | - -#### Definitions - -| Symbol | Meaning | -|---|---| -| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\mathcal{S}`$ — what a future costs to run — the operating terms, before their weight | - -### Objective - -```yaml -objective: - sense: minimize - description: capacity once, operation in expectation, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR -``` - -```math -\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} + \left( 1 - \omega \right) \cdot \left( \sum_{s \in \mathcal{S}} \pi_{s} \cdot \mathit{scenario\_opex}_{s} \right) + \omega \cdot CVaR -``` - -### `Generator-fix-p-lower` - -`Generator_fix_p_lower` - -```yaml -Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [scenario, snapshot, generator] - where: not Generator_p_nom_extendable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom -``` - -```math -p_{s,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} -``` - -### `Generator-fix-p-upper` - -`Generator_fix_p_upper` - -```yaml -Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [scenario, snapshot, generator] - where: not Generator_p_nom_extendable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom -``` - -```math -p_{s,t,g} \le \overline{\mathrm{p}}_{s,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} -``` - -### `Generator-ext-p-lower` - -`Generator_ext_p_lower` - -```yaml -Generator_ext_p_lower: - description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [scenario, snapshot, generator] - where: Generator_p_nom_extendable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext -``` - -```math -p_{s,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - -### `Generator-ext-p-upper` - -`Generator_ext_p_upper` - -```yaml -Generator_ext_p_upper: - description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [scenario, snapshot, generator] - where: Generator_p_nom_extendable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext -``` - -```math -p_{s,t,g} \le \overline{\mathrm{p}}_{s,t,g} \cdot P_{g} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - -### `Generator-ext-p_nom-lower` - -`Generator_ext_p_nom_lower` - -```yaml -Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] - where: Generator_p_nom_extendable - expression: Generator_p_nom_ext >= Generator_p_nom_min -``` - -```math -P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - -### `Generator-ext-p_nom-upper` - -`Generator_ext_p_nom_upper` - -```yaml -Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] - where: Generator_p_nom_extendable AND Generator_p_nom_max - expression: Generator_p_nom_ext <= Generator_p_nom_max -``` - -```math -P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \text{ is defined} -``` - -### `Link-fix-p-lower` - -`Link_fix_p_lower` - -```yaml -Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [scenario, snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom -``` - -```math -f_{s,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Link-fix-p-upper` - -`Link_fix_p_upper` - -```yaml -Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [scenario, snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom -``` - -```math -f_{s,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Bus-nodal_balance` - -`Bus_nodal_balance` - -```yaml -Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [scenario, snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) -``` - -```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{s,t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{s,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{s,t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{s,t,d} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ n \in \mathcal{N} -``` - -### `CVaR-excess-{s}` - -`CVaR_excess` - -```yaml -CVaR_excess: - description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" - dims: [scenario] - expression: CVaR_a - scenario_opex + CVaR_theta >= 0 -``` - -```math -a_{s} - \mathit{scenario\_opex}_{s} + \theta \ge 0 \qquad \forall\, s \in \mathcal{S} -``` - -### `CVaR-def` - -`CVaR_def` - -```yaml -CVaR_def: - description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" - dims: [] - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR -``` - -```math -\theta + \mathrm{v} \cdot \left( \sum_{s \in \mathcal{S}} \pi_{s} \cdot a_{s} \right) \le CVaR -``` - -### `scenario_opex` - -```yaml -scenario_opex: - description: what a future costs to run — the operating terms, before their weight - expression: >- - sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective, over=generator), over=snapshot) - + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective, over=link), over=snapshot) -``` - -```math -\mathit{scenario\_opex}_{s} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{s,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{s,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \qquad \forall\, s \in \mathcal{S} -``` - -#### Variable domains - -**`Generator_p`** - -```math -p_{s,t,g} \in \mathbb{R} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ g \in \mathcal{G} -``` - -**`Link_p`** - -```math -f_{s,t,l} \in \mathbb{R} \qquad \forall\, s \in \mathcal{S},\ t \in \mathcal{T},\ l \in \mathcal{L} -``` - -**`Generator_p_nom_ext`** - -```math -P_{g} \in \mathbb{R} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} -``` - -**`CVaR_a`** - -```math -a_{s} \ge 0 \qquad \forall\, s \in \mathcal{S} -``` - -**`CVaR_theta`** - -```math -\theta \in \mathbb{R} -``` - -**`CVaR`** - -```math -CVaR \in \mathbb{R} -``` - - -Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 663e07de..f4640fb7 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -3,16 +3,19 @@ # SPDX-License-Identifier: MIT description: >- - The model a plain `n.optimize()` builds, stated in one file. Every - declaration is named `Component_attribute` after the PyPSA statement it - stands for, and each constraint's description opens with the linopy name - PyPSA gives that row, so the two can be read side by side. PyPSA's regimes - — extendable, committable — are data columns and become `where:` masks. - Bounds are the explicit rows PyPSA writes, so their duals are row duals. - Parameters no PyPSA table carries verbatim are computed in data prep and say - so in their description. + A plain `n.optimize()`, and its multi-period and stochastic classes, in one + file. Every second-stage quantity spans a `scenario` (a future dispatch is + chosen in) and every asset stands in the investment `period`s its build year + and lifetime span. Capacity is chosen once, before the future is known, and + paid once per active period; operation is the expectation over the scenarios' + weights, with a share priced at the tail through the CVaR rows. A plain run + feeds one scenario, one period, all-active masks and unit weights, and the + model collapses to the standard one. Which snapshots an asset is active in, + and a scenario's weight, are data prep. dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight snapshot: description: dispatch periods dtype: datetime @@ -39,6 +42,11 @@ dimensions: description: independent cycles of the passive network graph — the cycle basis, data prep global_constraint: description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per parameters: snapshot_weightings_objective: @@ -167,7 +175,63 @@ parameters: dims: [snapshot, link] Load_p_set: description: demand - dims: [snapshot, load] + dims: [scenario, snapshot, load] + scenario_weight: + description: PyPSA's `scenario_weightings.weight` — the probability of a future + dims: [scenario] + CVaR_omega: + description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model + dims: [] + CVaR_inv_tail: + description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + dims: [] + period_weight_objective: + description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs + dims: [period] + Generator_active: + description: whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep + dims: [snapshot, generator] + dtype: bool + Link_active: + description: whether a link stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, link] + dtype: bool + StorageUnit_active: + description: whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, storage_unit] + dtype: bool + Store_active: + description: whether a store stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, store] + dtype: bool + Line_active: + description: whether a line stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, line] + dtype: bool + Generator_capital_weight: + description: the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [generator] + Link_capital_weight: + description: the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [link] + StorageUnit_capital_weight: + description: the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [storage_unit] + Store_capital_weight: + description: the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [store] + Line_capital_weight: + description: the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [line] + Generator_first_active: + description: one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, generator] + Carrier_max_growth: + description: most capacity of a carrier that may be added in a period; no value means no limit + dims: [carrier] + Carrier_max_relative_growth: + description: share of the previous period's additions that may be added on top + dims: [carrier] Generator_p_set: description: a given output schedule; a generator without one has no row here dims: [snapshot, generator] @@ -420,6 +484,14 @@ parameters: dims: [global_constraint, store] relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Generator_carrier: + description: the carrier a generator converts from + key: generator + values: carrier Generator_bus: description: the bus a generator sits on key: generator @@ -463,39 +535,46 @@ relations: variables: Generator_p: description: "`Generator-p` — output of a generator in a snapshot" - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] + where: Generator_active Link_p: description: >- `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to - dims: [snapshot, link] + dims: [scenario, snapshot, link] + where: Link_active StorageUnit_p_dispatch: description: "`StorageUnit-p_dispatch` — power delivered to the bus" - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active StorageUnit_p_store: description: "`StorageUnit-p_store` — power drawn from the bus into charge" - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active StorageUnit_state_of_charge: description: "`StorageUnit-state_of_charge` — energy held at the end of a snapshot" - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active StorageUnit_spill: description: >- `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows - dims: [snapshot, storage_unit] - where: "StorageUnit_inflow > 0" + dims: [scenario, snapshot, storage_unit] + where: "StorageUnit_inflow > 0 AND StorageUnit_active" absence: zero bounds: lower: 0 upper: StorageUnit_inflow Store_e: description: "`Store-e` — energy held at the end of a snapshot" - dims: [snapshot, store] + dims: [scenario, snapshot, store] + where: Store_active Store_p: description: "`Store-p` — power delivered to the bus; charging is negative" - dims: [snapshot, store] + dims: [scenario, snapshot, store] + where: Store_active Generator_n_mod: description: "`Generator-n_mod` — how many modules of an extendable modular build" dims: [generator] @@ -508,22 +587,22 @@ variables: `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active domain: integer bounds: lower: 0 Generator_start_up: description: "`Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active domain: integer bounds: lower: 0 Generator_shut_down: description: "`Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active domain: integer bounds: lower: 0 @@ -531,7 +610,8 @@ variables: description: >- `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless - dims: [snapshot, line] + dims: [scenario, snapshot, line] + where: Line_active Line_s_nom_ext: description: >- `Line-s_nom` — nominal apparent power where it is a decision; the @@ -562,13 +642,24 @@ variables: of the same PyPSA name carries the fixed regime dims: [store] where: Store_e_nom_extendable + CVaR_a: + description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" + dims: [scenario] + bounds: + lower: 0 + CVaR_theta: + description: "`CVaR-theta` — where the tail starts, the value at risk" + dims: [] + CVaR: + description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" + dims: [] expressions: Generator_previous_status: description: >- the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: opening: { when: "position(snapshot) == 0", expression: Generator_status_initial } otherwise: shift(Generator_status, along=snapshot, offset=1) @@ -577,7 +668,7 @@ expressions: the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: opening: { when: "position(snapshot) == 0", expression: 0 } otherwise: shift(Generator_p, along=snapshot, offset=1) @@ -592,7 +683,7 @@ expressions: how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: committed: when: Generator_committable @@ -606,7 +697,7 @@ expressions: how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] cases: committed: when: Generator_committable @@ -627,7 +718,7 @@ expressions: standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] cases: cyclic: when: StorageUnit_cyclic_state_of_charge @@ -642,7 +733,7 @@ expressions: standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise - dims: [snapshot, store] + dims: [scenario, snapshot, store] cases: cyclic: when: Store_e_cyclic @@ -659,7 +750,7 @@ expressions: is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not - dims: [snapshot, link_output] + dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay @@ -701,37 +792,50 @@ expressions: + sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + scenario_opex: + description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight + expression: >- + sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) constraints: Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - where: not Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" - dims: [snapshot, link] - where: not Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom Generator_ext_p_lower: description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext Generator_ext_p_upper: description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND not Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext Generator_ext_p_nom_lower: description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" @@ -750,23 +854,23 @@ constraints: expression: Generator_p_nom_ext == Generator_p_nom_set Generator_e_sum_min: description: "`Generator-e_sum_min` — energy over the horizon is at least its floor; a floor of minus infinity is no row" - dims: [generator] + dims: [scenario, generator] where: Generator_e_sum_min expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) >= Generator_e_sum_min Generator_e_sum_max: description: "`Generator-e_sum_max` — energy over the horizon is at most its budget; a budget of infinity is no row" - dims: [generator] + dims: [scenario, generator] where: Generator_e_sum_max expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) <= Generator_e_sum_max Link_ext_p_lower: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" - dims: [snapshot, link] - where: Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom_ext Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" - dims: [snapshot, link] - where: Link_p_nom_extendable + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom_ext Link_ext_p_nom_lower: description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor" @@ -785,83 +889,83 @@ constraints: expression: Link_p_nom_ext == Link_p_nom_set StorageUnit_fix_p_dispatch_lower: description: "`StorageUnit-fix-p_dispatch-lower` — dispatch is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch >= 0 StorageUnit_fix_p_dispatch_upper: description: "`StorageUnit-fix-p_dispatch-upper` — a fixed unit dispatches at most its nominal power" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom StorageUnit_fix_p_store_lower: description: "`StorageUnit-fix-p_store-lower` — storing is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store >= 0 StorageUnit_fix_p_store_upper: description: >- `StorageUnit-fix-p_store-upper` — a fixed unit stores at most its nominal power, the minimum-per-unit column carrying that cap negated - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom StorageUnit_fix_state_of_charge_lower: description: "`StorageUnit-fix-state_of_charge-lower` — charge is non-negative" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge >= 0 StorageUnit_fix_state_of_charge_upper: description: "`StorageUnit-fix-state_of_charge-upper` — a fixed unit holds at most its hours at nominal power" - dims: [snapshot, storage_unit] - where: not StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom Generator_com_p_lower: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" - dims: [snapshot, generator] - where: Generator_committable AND not Generator_p_nom_extendable + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" - dims: [snapshot, generator] - where: Generator_committable AND not Generator_p_nom_extendable + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status Generator_com_transition_start_up: description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active expression: Generator_start_up >= Generator_status - Generator_previous_status Generator_com_transition_shut_down: description: "`Generator-com-transition-shut-down` — turning off is a stop, counted against the state the unit carried into the snapshot" - dims: [snapshot, generator] - where: Generator_committable + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active expression: Generator_shut_down >= Generator_previous_status - Generator_status Generator_com_up_time: description: >- `Generator-com-up-time` — a unit started within its own minimum up time is still on. The first snapshot's share of the window is the brought-in up time's, which the must-stay-up mask carries - dims: [snapshot, generator] - where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status Generator_com_down_time: description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" - dims: [snapshot, generator] - where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status Generator_com_status_must_stay_up: description: "`Generator-com-status-min_up_time_must_stay_up` — a unit still serving the up time it brought in stays on" - dims: [snapshot, generator] - where: Generator_committable AND Generator_must_stay_up + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_up AND Generator_active expression: Generator_status == 1 Generator_p_ramp_limit_up_run_big_m: description: >- `Generator-p-ramp_limit_up-run-bigM` — a committed extendable unit raises output no faster than its limit of the chosen build; the big M releases the row in the snapshot it turns on - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_limit_up * Generator_p_nom_ext @@ -871,10 +975,10 @@ constraints: `Generator-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a committed extendable unit ramps no further than its start-up ramp of the chosen build; the big M releases the row everywhere else - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_limit_start_up * Generator_p_nom_ext @@ -884,10 +988,10 @@ constraints: `Generator-p-ramp_limit_down-run-bigM` — a committed extendable unit lowers output no faster than its limit of the chosen build; the big M releases the row in the snapshot it turns off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_limit_down * Generator_p_nom_ext @@ -897,10 +1001,10 @@ constraints: `Generator-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, a committed extendable unit ramps no further than its shut-down ramp of the chosen build; the big M releases the row everywhere else - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down - AND (position(snapshot) > 0 OR Generator_status_initial == 0) + AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_limit_shut_down * Generator_p_nom_ext @@ -914,20 +1018,20 @@ constraints: description: >- `Generator-com-ext-p-upper-cap` — a committed extendable unit outputs at most what is available of the chosen build, whatever its status - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext Generator_com_ext_p_upper_big_m: description: "`Generator-com-ext-p-upper-bigM` — off, a unit outputs nothing; on, the big M is no bound" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p <= Generator_big_m * Generator_status Generator_com_ext_p_lower: description: >- `Generator-com-ext-p-lower` — a committed extendable unit outputs at least its minimum of the chosen build; off, the big M releases the row - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: >- Generator_p >= Generator_p_min_pu * Generator_p_nom_ext @@ -937,83 +1041,83 @@ constraints: `Generator-com-ext-p-lower-nonneg` — where no minimum-per-unit is negative, output is also plainly non-negative, a row the big-M lower cannot assert while the unit is off - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_committable AND Generator_p_nom_extendable - AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) + AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: Generator_p >= 0 Generator_com_mod_p_lower: description: >- `Generator-com-mod-p-lower` — a committed modular unit outputs at least its minimum of one module, whether the build is fixed or a decision - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * Generator_status Generator_com_mod_p_upper: description: >- `Generator-com-mod-p-upper` — a committed modular unit outputs at most one module's share, whether the build is fixed or a decision - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * Generator_status Generator_status_p_fixed_upper: description: >- `Generator-status-p-fixed-upper` — a status is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_status <= Generator_modules_installed Generator_start_up_p_fixed_upper: description: >- `Generator-start_up-p-fixed-upper` — a start is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_start_up <= Generator_modules_installed Generator_shut_down_p_fixed_upper: description: >- `Generator-shut_down-p-fixed-upper` — a stop is at most the modules in place, an explicit row as PyPSA writes it: one where the build is not modular, and the fixed build's whole count of modules where it is - dims: [snapshot, generator] - where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active expression: Generator_shut_down <= Generator_modules_installed Generator_status_p_nom_variable_upper: description: "`Generator-status-p_nom-variable-upper` — a modular unit is on only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_status <= Generator_n_mod Generator_start_up_p_nom_variable_upper: description: "`Generator-start_up-p_nom-variable-upper` — a modular unit starts only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_start_up <= Generator_n_mod Generator_shut_down_p_nom_variable_upper: description: "`Generator-shut_down-p_nom-variable-upper` — a modular unit stops only where a module is built" - dims: [snapshot, generator] - where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_shut_down <= Generator_n_mod Line_fix_s_lower: description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating" - dims: [snapshot, line] - where: not Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active expression: Line_s >= -Line_s_max_pu * Line_s_nom Line_fix_s_upper: description: "`Line-fix-s-upper` — a fixed line carries at most its rating" - dims: [snapshot, line] - where: not Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active expression: Line_s <= Line_s_max_pu * Line_s_nom Line_ext_s_lower: description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active expression: Line_s >= -Line_s_max_pu * Line_s_nom_ext Line_ext_s_upper: description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active expression: Line_s <= Line_s_max_pu * Line_s_nom_ext Line_ext_s_nom_lower: description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" @@ -1032,15 +1136,15 @@ constraints: expression: Line_s_nom_ext == Line_s_nom_set Line_s_set: description: "`Line-s_set` — flow pinned to the given schedule, wherever one is given" - dims: [snapshot, line] - where: Line_s_set + dims: [scenario, snapshot, line] + where: Line_s_set AND Line_active expression: Line_s == Line_s_set Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear power flow physical rather than transport - dims: [snapshot, cycle] + dims: [scenario, snapshot, cycle] expression: sum(Line_s * Line_cycle_weight, over=line) == 0 Generator_p_ramp_limit_up: description: >- @@ -1049,11 +1153,11 @@ constraints: start-up ramp in the snapshot it turns on. A unit that came into the horizon running brought an unknown output, so it carries no row at the first snapshot — nor does any unit a big M releases instead - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_ramp_limit_up AND NOT (Generator_committable AND Generator_p_nom_extendable) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) + AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance Generator_p_ramp_limit_down: description: >- @@ -1062,56 +1166,56 @@ constraints: shut-down ramp in the snapshot it turns off. A unit that came into the horizon running brought an unknown output, so it carries no row at the first snapshot — nor does any unit a big M releases instead - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] where: >- Generator_ramp_limit_down AND NOT (Generator_committable AND Generator_p_nom_extendable) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) + AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Link_p_ramp_limit_up: description: >- `Link-p-ramp_limit_up` — a link raises flow no faster than its limit of the build. The translated term vacates the first snapshot, where a plain optimize builds no row either - dims: [snapshot, link] - where: Link_ramp_limit_up + dims: [scenario, snapshot, link] + where: Link_ramp_limit_up AND Link_active expression: Link_p - shift(Link_p, along=snapshot, offset=1) <= Link_ramp_limit_up * Link_p_nom_effective Link_p_ramp_limit_down: description: "`Link-p-ramp_limit_down` — a link lowers flow no faster than its limit of the build" - dims: [snapshot, link] - where: Link_ramp_limit_down + dims: [scenario, snapshot, link] + where: Link_ramp_limit_down AND Link_active expression: shift(Link_p, along=snapshot, offset=1) - Link_p <= Link_ramp_limit_down * Link_p_nom_effective StorageUnit_ext_p_dispatch_lower: description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch >= 0 StorageUnit_ext_p_dispatch_upper: description: "`StorageUnit-ext-p_dispatch-upper` — an extendable unit dispatches at most the chosen build" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom_ext StorageUnit_ext_p_store_lower: description: "`StorageUnit-ext-p_store-lower` — storing is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store >= 0 StorageUnit_ext_p_store_upper: description: >- `StorageUnit-ext-p_store-upper` — an extendable unit stores at most the chosen build, the minimum-per-unit column carrying that cap negated - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom_ext StorageUnit_ext_state_of_charge_lower: description: "`StorageUnit-ext-state_of_charge-lower` — charge is non-negative" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge >= 0 StorageUnit_ext_state_of_charge_upper: description: "`StorageUnit-ext-state_of_charge-upper` — an extendable unit holds at most its hours at the chosen build" - dims: [snapshot, storage_unit] - where: StorageUnit_p_nom_extendable + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom_ext StorageUnit_ext_p_nom_lower: description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor" @@ -1133,7 +1237,8 @@ constraints: `StorageUnit-energy_balance` — the charge carried in, plus what is stored after its efficiency, less what dispatch draws down before its own, plus inflow not spilled - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active expression: >- StorageUnit_state_of_charge == StorageUnit_charge_carried_in @@ -1142,23 +1247,23 @@ constraints: + (StorageUnit_inflow - StorageUnit_spill) * snapshot_weightings_stores Store_fix_e_lower: description: "`Store-fix-e-lower` — a fixed store holds at least its floor" - dims: [snapshot, store] - where: not Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active expression: Store_e >= Store_e_min_pu * Store_e_nom Store_fix_e_upper: description: "`Store-fix-e-upper` — a fixed store holds at most its nominal capacity" - dims: [snapshot, store] - where: not Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active expression: Store_e <= Store_e_max_pu * Store_e_nom Store_ext_e_lower: description: "`Store-ext-e-lower` — an extendable store holds at least its floor of the chosen build" - dims: [snapshot, store] - where: Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active expression: Store_e >= Store_e_min_pu * Store_e_nom_ext Store_ext_e_upper: description: "`Store-ext-e-upper` — an extendable store holds at most the chosen build" - dims: [snapshot, store] - where: Store_e_nom_extendable + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active expression: Store_e <= Store_e_max_pu * Store_e_nom_ext Store_ext_e_nom_lower: description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor" @@ -1177,64 +1282,65 @@ constraints: expression: Store_e_nom_ext == Store_e_nom_set Store_energy_balance: description: "`Store-energy_balance` — the energy carried in, less what is delivered to the bus" - dims: [snapshot, store] + dims: [scenario, snapshot, store] + where: Store_active expression: >- Store_e == Store_energy_carried_in - Store_p * snapshot_weightings_stores Generator_p_set: description: "`Generator-p_set` — output pinned to the given schedule, wherever one is given" - dims: [snapshot, generator] - where: Generator_p_set + dims: [scenario, snapshot, generator] + where: Generator_p_set AND Generator_active expression: Generator_p == Generator_p_set Link_p_set: description: "`Link-p_set` — flow pinned to the given schedule, wherever one is given" - dims: [snapshot, link] - where: Link_p_set + dims: [scenario, snapshot, link] + where: Link_p_set AND Link_active expression: Link_p == Link_p_set StorageUnit_p_set: description: "`StorageUnit-p_set` — net dispatch pinned to the given schedule, wherever one is given" - dims: [snapshot, storage_unit] - where: StorageUnit_p_set + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_set AND StorageUnit_active expression: StorageUnit_p_dispatch - StorageUnit_p_store == StorageUnit_p_set StorageUnit_state_of_charge_set: description: "`StorageUnit-state_of_charge_set` — charge pinned to the given schedule, wherever one is given" - dims: [snapshot, storage_unit] - where: StorageUnit_state_of_charge_set + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_state_of_charge_set AND StorageUnit_active expression: StorageUnit_state_of_charge == StorageUnit_state_of_charge_set Store_e_set: description: "`Store-e_set` — energy pinned to the given schedule, wherever one is given" - dims: [snapshot, store] - where: Store_e_set + dims: [scenario, snapshot, store] + where: Store_e_set AND Store_active expression: Store_e == Store_e_set GlobalConstraint_primary_energy_ub: description: "`primary_energy` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '<=' expression: primary_energy <= GlobalConstraint_constant GlobalConstraint_primary_energy_lb: description: "`primary_energy` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '>=' expression: primary_energy >= GlobalConstraint_constant GlobalConstraint_primary_energy_eq: description: "`primary_energy` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '==' expression: primary_energy == GlobalConstraint_constant GlobalConstraint_operational_limit_ub: description: "`operational_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '<=' expression: operational_limit <= GlobalConstraint_constant GlobalConstraint_operational_limit_lb: description: "`operational_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '>=' expression: operational_limit >= GlobalConstraint_constant GlobalConstraint_operational_limit_eq: description: "`operational_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '==' expression: operational_limit == GlobalConstraint_constant GlobalConstraint_transmission_volume_expansion_limit_ub: @@ -1290,7 +1396,7 @@ constraints: load there. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. - dims: [snapshot, bus] + dims: [scenario, snapshot, bus] expression: >- sum(Generator_p, by=Generator_bus, over=generator, into=bus) + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus, over=storage_unit, into=bus) @@ -1300,23 +1406,35 @@ constraints: - sum(Line_s, by=Line_bus0, over=line, into=bus) + sum(Line_s, by=Line_bus1, over=line, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) + Carrier_growth_limit: + description: >- + `Carrier-growth_limit` — what a carrier adds in a period, counting each build in the first + period it stands in, is at most its allowance plus a share of what it added the period before; + the first period has no predecessor, so `edge=0` leaves it the bare allowance + dims: [carrier, period] + where: Carrier_max_growth + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) + * Carrier_max_relative_growth + <= Carrier_max_growth + CVaR_excess: + description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" + dims: [scenario] + expression: CVaR_a - scenario_opex + CVaR_theta >= 0 + CVaR_def: + description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" + dims: [] + expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR objective: sense: minimize - description: operating cost, each snapshot weighted by the hours it stands for + description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective) - + sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective) - + sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective) - + sum(Store_p * Store_marginal_cost * snapshot_weightings_objective) - + sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective) - + sum(Generator_p_nom_ext * Generator_capital_cost) - + sum(Link_p_nom_ext * Link_capital_cost) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost) - + sum(Store_e_nom_ext * Store_capital_cost) - + sum(Line_s_nom_ext * Line_capital_cost) - + sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective) - + sum(Generator_start_up * Generator_start_up_cost) - + sum(Generator_shut_down * Generator_shut_down_cost) + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR diff --git a/examples/pypsa_multi_period.yaml b/examples/pypsa_multi_period.yaml deleted file mode 100644 index 97a31c34..00000000 --- a/examples/pypsa_multi_period.yaml +++ /dev/null @@ -1,229 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -description: >- - The multi-period class of a plain `n.optimize()`: - `multi_investment_periods`, stated on rungs 1 and 3 in a file of its own. - A snapshot belongs to an investment period, an asset stands in the periods - its build year and lifetime span, and capacity is paid once per period it - stands in, each period weighted; a carrier may grow only so much per - period. Which snapshots an asset is active in is data prep, because a - `where` reaches only the frame's own dimensions. A dimension a run may not - have cannot ride on `examples/pypsa.yaml`, so this class lives here. - -dimensions: - snapshot: - description: dispatch periods, positions across every investment period - dtype: datetime - period: - description: investment periods — PyPSA's `investment_periods` - dtype: int - bus: - description: network nodes - generator: - description: generating units, each on one bus - link: - description: controllable connections, each from one bus to the buses it delivers to - link_output: - description: >- - a link's output ports, one label per port a link declares — PyPSA's - `bus1`, `bus2`, … columns read long, so a link of any number of output - ports is one term in the balance, data prep - load: - description: demands, each on one bus - carrier: - description: energy carriers, what a growth limit is set per - -parameters: - snapshot_weightings_objective: - description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost - dims: [snapshot] - period_weight_objective: - description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs - dims: [period] - Generator_active: - description: whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep - dims: [snapshot, generator] - dtype: bool - Generator_capital_weight: - description: the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep - dims: [generator] - Generator_first_active: - description: one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep - dims: [period, generator] - Carrier_max_growth: - description: most capacity of a carrier that may be added in a period; no value means no limit - dims: [carrier] - Carrier_max_relative_growth: - description: share of the previous period's additions that may be added on top - dims: [carrier] - Generator_p_nom: - description: nominal power - dims: [generator] - Generator_p_nom_extendable: - description: whether the nominal power is a decision - dims: [generator] - dtype: bool - Generator_p_nom_min: - description: least nominal power an extendable generator may be built at - dims: [generator] - Generator_p_nom_max: - description: most nominal power an extendable generator may be built at - dims: [generator] - Generator_capital_cost: - description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [generator] - Generator_p_min_pu: - description: least output, per unit of nominal power - dims: [snapshot, generator] - Generator_p_max_pu: - description: most output, per unit of nominal power — an availability profile - dims: [snapshot, generator] - Generator_marginal_cost: - description: cost of one unit of output - dims: [snapshot, generator] - Link_p_nom: - description: nominal power - dims: [link] - Link_p_min_pu: - description: least flow, per unit of nominal power — negative for a link that carries both ways - dims: [snapshot, link] - Link_p_max_pu: - description: most flow, per unit of nominal power - dims: [snapshot, link] - Link_efficiency: - description: >- - share of the flow that arrives at an output port, PyPSA's `efficiency`, - `efficiency2`, … read long — negative where that port consumes rather - than delivers - dims: [link_output] - Link_marginal_cost: - description: cost of one unit of flow - dims: [snapshot, link] - Load_p_set: - description: demand - dims: [snapshot, load] - -relations: - snapshot_period: - description: the investment period a snapshot falls in - key: snapshot - values: period - Generator_carrier: - description: the carrier a generator converts from - key: generator - values: carrier - Generator_bus: - description: the bus a generator sits on - key: generator - values: bus - Link_bus0: - description: the bus a link leaves - key: link - values: bus - Link_output_link: - description: the link an output port belongs to - key: link_output - values: link - Link_output_bus: - description: >- - the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. - A link of three output ports is three labels here rather than a third - relation, so the file states any number of them - key: link_output - values: bus - Load_bus: - description: the bus a load sits on - key: load - values: bus - -variables: - Generator_p: - description: "`Generator-p` — output of a generator in a snapshot" - dims: [snapshot, generator] - where: Generator_active - Link_p: - description: >- - `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a - positive value withdraws there and injects at every bus the link's - output ports deliver to - dims: [snapshot, link] - Generator_p_nom_ext: - description: >- - `Generator-p_nom` — nominal power where it is a decision; the parameter - of the same PyPSA name carries the fixed regime - dims: [generator] - where: Generator_p_nom_extendable - -constraints: - Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom - Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - where: not Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom - Generator_ext_p_lower: - description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext - Generator_ext_p_upper: - description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [snapshot, generator] - where: Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext - Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] - where: Generator_p_nom_extendable - expression: Generator_p_nom_ext >= Generator_p_nom_min - Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] - where: Generator_p_nom_extendable AND Generator_p_nom_max - expression: Generator_p_nom_ext <= Generator_p_nom_max - Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom - Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom - Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) - Carrier_growth_limit: - description: >- - `Carrier-growth_limit` — what a carrier adds in a period, counting each - build in the first period it stands in, is at most its allowance plus a - share of what it added the period before; the first period has no - predecessor, so `edge=0` leaves it the bare allowance - dims: [carrier, period] - where: Carrier_max_growth - expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) - * Carrier_max_relative_growth - <= Carrier_max_growth - -objective: - sense: minimize - description: operating cost by weighted snapshot and weighted period, and capacity once per period it stands in - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot)) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot)) - + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) diff --git a/examples/pypsa_stochastic.yaml b/examples/pypsa_stochastic.yaml deleted file mode 100644 index a5ebe4fb..00000000 --- a/examples/pypsa_stochastic.yaml +++ /dev/null @@ -1,220 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -description: >- - The two-stage class of a plain `n.optimize()`: a network with scenarios, - stated on rung 1's transport and rung 3's expansion in a file of its own. - Everything over a snapshot spans a scenario as well; capacity does not — - it is chosen once, before the future is known — and the cost is the - expectation over the scenarios' weights. With a risk preference PyPSA adds - the CVaR rows: an excess per scenario and the tail's average, blended into - the objective. A dimension a run may not have cannot ride on - `examples/pypsa.yaml`, so this class lives here. - -dimensions: - scenario: - description: the futures dispatch is chosen in, each with a weight - snapshot: - description: dispatch periods - dtype: datetime - bus: - description: network nodes - generator: - description: generating units, each on one bus - link: - description: controllable connections, each from one bus to the buses it delivers to - link_output: - description: >- - a link's output ports, one label per port a link declares — PyPSA's - `bus1`, `bus2`, … columns read long, so a link of any number of output - ports is one term in the balance, data prep - load: - description: demands, each on one bus - -parameters: - scenario_weight: - description: PyPSA's `scenario_weightings.weight` — the probability of a future - dims: [scenario] - CVaR_omega: - description: PyPSA's `risk_preference['omega']` — the share of the operating cost priced at the tail rather than in expectation - dims: [] - CVaR_inv_tail: - description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor - dims: [] - snapshot_weightings_objective: - description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost - dims: [snapshot] - Generator_p_nom: - description: nominal power - dims: [generator] - Generator_p_nom_extendable: - description: whether the nominal power is a decision - dims: [generator] - dtype: bool - Generator_p_nom_min: - description: least nominal power an extendable generator may be built at - dims: [generator] - Generator_p_nom_max: - description: most nominal power an extendable generator may be built at - dims: [generator] - Generator_capital_cost: - description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [generator] - Generator_p_min_pu: - description: least output, per unit of nominal power - dims: [snapshot, generator] - Generator_p_max_pu: - description: most output, per unit of nominal power — an availability profile - dims: [scenario, snapshot, generator] - Generator_marginal_cost: - description: cost of one unit of output - dims: [snapshot, generator] - Link_p_nom: - description: nominal power - dims: [link] - Link_p_min_pu: - description: least flow, per unit of nominal power — negative for a link that carries both ways - dims: [snapshot, link] - Link_p_max_pu: - description: most flow, per unit of nominal power - dims: [snapshot, link] - Link_efficiency: - description: >- - share of the flow that arrives at an output port, PyPSA's `efficiency`, - `efficiency2`, … read long — negative where that port consumes rather - than delivers - dims: [link_output] - Link_marginal_cost: - description: cost of one unit of flow - dims: [snapshot, link] - Load_p_set: - description: demand - dims: [scenario, snapshot, load] - -relations: - Generator_bus: - description: the bus a generator sits on - key: generator - values: bus - Link_bus0: - description: the bus a link leaves - key: link - values: bus - Link_output_link: - description: the link an output port belongs to - key: link_output - values: link - Link_output_bus: - description: >- - the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. - A link of three output ports is three labels here rather than a third - relation, so the file states any number of them - key: link_output - values: bus - Load_bus: - description: the bus a load sits on - key: load - values: bus - -variables: - Generator_p: - description: "`Generator-p` — output of a generator in a snapshot" - dims: [scenario, snapshot, generator] - Link_p: - description: >- - `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a - positive value withdraws there and injects at every bus the link's - output ports deliver to - dims: [scenario, snapshot, link] - Generator_p_nom_ext: - description: >- - `Generator-p_nom` — nominal power where it is a decision; the parameter - of the same PyPSA name carries the fixed regime - dims: [generator] - where: Generator_p_nom_extendable - CVaR_a: - description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" - dims: [scenario] - bounds: - lower: 0 - CVaR_theta: - description: "`CVaR-theta` — where the tail starts, the value at risk" - dims: [] - CVaR: - description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" - dims: [] - -expressions: - scenario_opex: - description: what a future costs to run — the operating terms, before their weight - expression: >- - sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective, over=generator), over=snapshot) - + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective, over=link), over=snapshot) - -constraints: - Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [scenario, snapshot, generator] - where: not Generator_p_nom_extendable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom - Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [scenario, snapshot, generator] - where: not Generator_p_nom_extendable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom - Generator_ext_p_lower: - description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" - dims: [scenario, snapshot, generator] - where: Generator_p_nom_extendable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext - Generator_ext_p_upper: - description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" - dims: [scenario, snapshot, generator] - where: Generator_p_nom_extendable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext - Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] - where: Generator_p_nom_extendable - expression: Generator_p_nom_ext >= Generator_p_nom_min - Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] - where: Generator_p_nom_extendable AND Generator_p_nom_max - expression: Generator_p_nom_ext <= Generator_p_nom_max - Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [scenario, snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom - Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [scenario, snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom - Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [scenario, snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) - CVaR_excess: - description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" - dims: [scenario] - expression: CVaR_a - scenario_opex + CVaR_theta >= 0 - CVaR_def: - description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" - dims: [] - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR - -objective: - sense: minimize - description: capacity once, operation in expectation, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR diff --git a/examples/references/pypsa/rung_14_stochastic.py b/examples/references/pypsa/rung_14_stochastic.py index f93c4ef5..24ae3f79 100644 --- a/examples/references/pypsa/rung_14_stochastic.py +++ b/examples/references/pypsa/rung_14_stochastic.py @@ -2,14 +2,12 @@ # # SPDX-License-Identifier: MIT -"""Rung 14: two futures and a risk preference — capacity chosen once, dispatch per scenario, stated by `pypsa_stochastic.yaml`.""" +"""Rung 14: two futures and a risk preference — capacity chosen once, dispatch per scenario.""" from __future__ import annotations import spine -MODEL = 'pypsa_stochastic.yaml' - def build(): """The spine plus an extendable wind unit whose availability and the south's load differ between a calm and a stormy future.""" diff --git a/examples/references/pypsa/rung_15_multi_period.py b/examples/references/pypsa/rung_15_multi_period.py index 220ae1ee..4d5d3981 100644 --- a/examples/references/pypsa/rung_15_multi_period.py +++ b/examples/references/pypsa/rung_15_multi_period.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""Rung 15: two investment periods — build years, lifetimes, period weights and a carrier's growth limit, stated by `pypsa_multi_period.yaml`.""" +"""Rung 15: two investment periods — build years, lifetimes, period weights and a carrier's growth limit.""" from __future__ import annotations @@ -10,7 +10,6 @@ import pandas as pd -MODEL = 'pypsa_multi_period.yaml' OPTIMIZE = {'multi_investment_periods': True} diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 5be16db1..9c871565 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -16,6 +16,9 @@ dimensions: store: { index: v, set: '\mathcal{V}' } line: { index: k, set: '\mathcal{K}' } cycle: { index: c, set: '\mathcal{C}' } + scenario: { index: '\xi', set: '\Xi' } + period: { index: y, set: '\mathcal{Y}' } + carrier: { index: i, set: '\mathcal{I}' } names: snapshot_weightings_objective: '\mathrm{w}' Generator_p: p @@ -153,3 +156,23 @@ names: Store_marginal_cost: '\mathrm{c}^{q}' Store_marginal_cost_storage: '\mathrm{c}^{e}' Store_e_set: '\mathrm{e}^{\mathrm{set}}' + scenario_weight: '\pi' + CVaR_omega: '\omega' + CVaR_inv_tail: '\mathrm{v}' + CVaR_a: a + CVaR_theta: '\theta' + CVaR: "CVaR" + period_weight_objective: '\mathrm{w}^{y}' + Generator_active: '\mathrm{on}' + Link_active: '\mathrm{on}^{f}' + StorageUnit_active: '\mathrm{on}^{h}' + Store_active: '\mathrm{on}^{e}' + Line_active: '\mathrm{on}^{s}' + Generator_capital_weight: '\mathrm{W}' + Link_capital_weight: '\mathrm{W}^{f}' + StorageUnit_capital_weight: '\mathrm{W}^{h}' + Store_capital_weight: '\mathrm{W}^{e}' + Line_capital_weight: '\mathrm{W}^{s}' + Generator_first_active: '\mathrm{new}' + Carrier_max_growth: '\overline{\Delta}' + Carrier_max_relative_growth: '\mathrm{r}' diff --git a/examples/symbols/pypsa_multi_period.yaml b/examples/symbols/pypsa_multi_period.yaml deleted file mode 100644 index 8fa00219..00000000 --- a/examples/symbols/pypsa_multi_period.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -# How `examples/pypsa_multi_period.yaml` prints — the transport and expansion symbols of `pypsa.yaml`, a period, a carrier. -notation: latex -dimensions: - snapshot: { index: t, set: '\mathcal{T}' } - bus: { index: n, set: '\mathcal{N}' } - generator: { index: g, set: '\mathcal{G}' } - link: { index: l, set: '\mathcal{L}' } - link_output: { index: o, set: '\mathcal{O}' } - load: { index: d, set: '\mathcal{D}' } - period: { index: y, set: '\mathcal{Y}' } - carrier: { index: c, set: '\mathcal{C}' } -names: - snapshot_weightings_objective: '\mathrm{w}' - Generator_p: p - Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' - Generator_p_nom_extendable: '\mathrm{ext}' - Generator_p_min_pu: '\underline{\mathrm{p}}' - Generator_p_max_pu: '\overline{\mathrm{p}}' - Generator_marginal_cost: '\mathrm{c}' - Link_p: f - Link_p_nom: '\mathrm{f}^{\mathrm{nom}}' - Link_p_min_pu: '\underline{\mathrm{f}}' - Link_p_max_pu: '\overline{\mathrm{f}}' - Link_efficiency: '\eta' - Link_marginal_cost: '\mathrm{c}^{f}' - Load_p_set: '\mathrm{load}' - Generator_p_nom_ext: P - Generator_p_nom_min: '\underline{\mathrm{p}}^{\mathrm{nom}}' - Generator_p_nom_max: '\overline{\mathrm{p}}^{\mathrm{nom}}' - Generator_capital_cost: '\mathrm{c}^{\mathrm{cap}}' - period_weight_objective: '\mathrm{w}^{y}' - Generator_active: '\mathrm{on}' - Generator_capital_weight: '\mathrm{W}' - Generator_first_active: '\mathrm{new}' - Carrier_max_growth: '\overline{\Delta}' - Carrier_max_relative_growth: '\mathrm{r}' diff --git a/examples/symbols/pypsa_stochastic.yaml b/examples/symbols/pypsa_stochastic.yaml deleted file mode 100644 index 2e64e671..00000000 --- a/examples/symbols/pypsa_stochastic.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -# How `examples/pypsa_stochastic.yaml` prints — the transport and expansion symbols of `pypsa.yaml`, a scenario, and the tail. -notation: latex -dimensions: - snapshot: { index: t, set: '\mathcal{T}' } - bus: { index: n, set: '\mathcal{N}' } - generator: { index: g, set: '\mathcal{G}' } - link: { index: l, set: '\mathcal{L}' } - link_output: { index: o, set: '\mathcal{O}' } - load: { index: d, set: '\mathcal{D}' } - scenario: { index: s, set: '\mathcal{S}' } -names: - snapshot_weightings_objective: '\mathrm{w}' - Generator_p: p - Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' - Generator_p_nom_extendable: '\mathrm{ext}' - Generator_p_min_pu: '\underline{\mathrm{p}}' - Generator_p_max_pu: '\overline{\mathrm{p}}' - Generator_marginal_cost: '\mathrm{c}' - Link_p: f - Link_p_nom: '\mathrm{f}^{\mathrm{nom}}' - Link_p_min_pu: '\underline{\mathrm{f}}' - Link_p_max_pu: '\overline{\mathrm{f}}' - Link_efficiency: '\eta' - Link_marginal_cost: '\mathrm{c}^{f}' - Load_p_set: '\mathrm{load}' - Generator_p_nom_ext: P - Generator_p_nom_min: '\underline{\mathrm{p}}^{\mathrm{nom}}' - Generator_p_nom_max: '\overline{\mathrm{p}}^{\mathrm{nom}}' - Generator_capital_cost: '\mathrm{c}^{\mathrm{cap}}' - scenario_weight: '\pi' - CVaR_omega: '\omega' - CVaR_inv_tail: '\mathrm{v}' - CVaR_a: a - CVaR_theta: '\theta' - CVaR: "CVaR" diff --git a/mkdocs.yml b/mkdocs.yml index ccc32aff..33d2c00b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,8 +66,6 @@ nav: - PyPSA, the quadratic class: examples/pypsa_quadratic.md - PyPSA, the relaxed commitment: examples/pypsa_linearized_uc.md - PyPSA, the lossy lines: examples/pypsa_losses.md - - PyPSA, the two-stage class: examples/pypsa_stochastic.md - - PyPSA, the multi-period class: examples/pypsa_multi_period.md # Everything a reader does not need in order to write a model: the design # arguments, how to contribute, what changed. - About: diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml new file mode 100644 index 00000000..2c6ac7f8 --- /dev/null +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -0,0 +1,607 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The shape of the standard PyPSA model, frozen before examples/pypsa.yaml +# became the unified superset. tests/test_pypsa_collapses.py asserts the +# superset, fed one scenario and all-active periods, reduces to exactly this. + +constraints: + Bus_nodal_balance: + - snapshot + - bus + Generator_com_down_time: + - snapshot + - generator + Generator_com_ext_p_lower: + - snapshot + - generator + Generator_com_ext_p_lower_nonneg: + - snapshot + - generator + Generator_com_ext_p_upper_big_m: + - snapshot + - generator + Generator_com_ext_p_upper_cap: + - snapshot + - generator + Generator_com_mod_p_lower: + - snapshot + - generator + Generator_com_mod_p_upper: + - snapshot + - generator + Generator_com_p_lower: + - snapshot + - generator + Generator_com_p_upper: + - snapshot + - generator + Generator_com_status_must_stay_up: + - snapshot + - generator + Generator_com_transition_shut_down: + - snapshot + - generator + Generator_com_transition_start_up: + - snapshot + - generator + Generator_com_up_time: + - snapshot + - generator + Generator_e_sum_max: + - generator + Generator_e_sum_min: + - generator + Generator_ext_p_lower: + - snapshot + - generator + Generator_ext_p_nom_lower: + - generator + Generator_ext_p_nom_upper: + - generator + Generator_ext_p_upper: + - snapshot + - generator + Generator_fix_p_lower: + - snapshot + - generator + Generator_fix_p_upper: + - snapshot + - generator + Generator_p_nom_modularity: + - generator + Generator_p_nom_set: + - generator + Generator_p_ramp_limit_down: + - snapshot + - generator + Generator_p_ramp_limit_down_run_big_m: + - snapshot + - generator + Generator_p_ramp_limit_down_shut_big_m: + - snapshot + - generator + Generator_p_ramp_limit_up: + - snapshot + - generator + Generator_p_ramp_limit_up_run_big_m: + - snapshot + - generator + Generator_p_ramp_limit_up_start_big_m: + - snapshot + - generator + Generator_p_set: + - snapshot + - generator + Generator_shut_down_p_fixed_upper: + - snapshot + - generator + Generator_shut_down_p_nom_variable_upper: + - snapshot + - generator + Generator_start_up_p_fixed_upper: + - snapshot + - generator + Generator_start_up_p_nom_variable_upper: + - snapshot + - generator + Generator_status_p_fixed_upper: + - snapshot + - generator + Generator_status_p_nom_variable_upper: + - snapshot + - generator + GlobalConstraint_operational_limit_eq: + - global_constraint + GlobalConstraint_operational_limit_lb: + - global_constraint + GlobalConstraint_operational_limit_ub: + - global_constraint + GlobalConstraint_primary_energy_eq: + - global_constraint + GlobalConstraint_primary_energy_lb: + - global_constraint + GlobalConstraint_primary_energy_ub: + - global_constraint + GlobalConstraint_tech_capacity_expansion_limit_eq: + - global_constraint + GlobalConstraint_tech_capacity_expansion_limit_lb: + - global_constraint + GlobalConstraint_tech_capacity_expansion_limit_ub: + - global_constraint + GlobalConstraint_transmission_expansion_cost_limit_eq: + - global_constraint + GlobalConstraint_transmission_expansion_cost_limit_lb: + - global_constraint + GlobalConstraint_transmission_expansion_cost_limit_ub: + - global_constraint + GlobalConstraint_transmission_volume_expansion_limit_eq: + - global_constraint + GlobalConstraint_transmission_volume_expansion_limit_lb: + - global_constraint + GlobalConstraint_transmission_volume_expansion_limit_ub: + - global_constraint + Kirchhoff_Voltage_Law: + - snapshot + - cycle + Line_ext_s_lower: + - snapshot + - line + Line_ext_s_nom_lower: + - line + Line_ext_s_nom_upper: + - line + Line_ext_s_upper: + - snapshot + - line + Line_fix_s_lower: + - snapshot + - line + Line_fix_s_upper: + - snapshot + - line + Line_s_nom_set: + - line + Line_s_set: + - snapshot + - line + Link_ext_p_lower: + - snapshot + - link + Link_ext_p_nom_lower: + - link + Link_ext_p_nom_upper: + - link + Link_ext_p_upper: + - snapshot + - link + Link_fix_p_lower: + - snapshot + - link + Link_fix_p_upper: + - snapshot + - link + Link_p_nom_set: + - link + Link_p_ramp_limit_down: + - snapshot + - link + Link_p_ramp_limit_up: + - snapshot + - link + Link_p_set: + - snapshot + - link + StorageUnit_energy_balance: + - snapshot + - storage_unit + StorageUnit_ext_p_dispatch_lower: + - snapshot + - storage_unit + StorageUnit_ext_p_dispatch_upper: + - snapshot + - storage_unit + StorageUnit_ext_p_nom_lower: + - storage_unit + StorageUnit_ext_p_nom_upper: + - storage_unit + StorageUnit_ext_p_store_lower: + - snapshot + - storage_unit + StorageUnit_ext_p_store_upper: + - snapshot + - storage_unit + StorageUnit_ext_state_of_charge_lower: + - snapshot + - storage_unit + StorageUnit_ext_state_of_charge_upper: + - snapshot + - storage_unit + StorageUnit_fix_p_dispatch_lower: + - snapshot + - storage_unit + StorageUnit_fix_p_dispatch_upper: + - snapshot + - storage_unit + StorageUnit_fix_p_store_lower: + - snapshot + - storage_unit + StorageUnit_fix_p_store_upper: + - snapshot + - storage_unit + StorageUnit_fix_state_of_charge_lower: + - snapshot + - storage_unit + StorageUnit_fix_state_of_charge_upper: + - snapshot + - storage_unit + StorageUnit_p_nom_set: + - storage_unit + StorageUnit_p_set: + - snapshot + - storage_unit + StorageUnit_state_of_charge_set: + - snapshot + - storage_unit + Store_e_nom_set: + - store + Store_e_set: + - snapshot + - store + Store_energy_balance: + - snapshot + - store + Store_ext_e_lower: + - snapshot + - store + Store_ext_e_nom_lower: + - store + Store_ext_e_nom_upper: + - store + Store_ext_e_upper: + - snapshot + - store + Store_fix_e_lower: + - snapshot + - store + Store_fix_e_upper: + - snapshot + - store +expressions: +- Generator_p_nom_effective +- Generator_previous_p +- Generator_previous_status +- Generator_ramp_down_allowance +- Generator_ramp_up_allowance +- Link_output_arrival +- Link_p_nom_effective +- StorageUnit_charge_carried_in +- Store_energy_carried_in +- operational_limit +- primary_energy +- tech_capacity_expansion +- transmission_expansion_cost +- transmission_volume_expansion +parameters: + Generator_big_m: + - generator + Generator_capital_cost: + - generator + Generator_committable: + - generator + Generator_e_sum_max: + - generator + Generator_e_sum_min: + - generator + Generator_marginal_cost: + - snapshot + - generator + Generator_min_down_time: + - generator + Generator_min_up_time: + - generator + Generator_modules_installed: + - generator + Generator_must_stay_up: + - snapshot + - generator + Generator_operational_limit_weight: + - global_constraint + - generator + Generator_p_max_pu: + - snapshot + - generator + Generator_p_min_pu: + - snapshot + - generator + Generator_p_min_pu_nonneg: + - generator + Generator_p_nom: + - generator + Generator_p_nom_extendable: + - generator + Generator_p_nom_max: + - generator + Generator_p_nom_min: + - generator + Generator_p_nom_mod: + - generator + Generator_p_nom_set: + - generator + Generator_p_set: + - snapshot + - generator + Generator_primary_energy_weight: + - global_constraint + - generator + Generator_ramp_limit_down: + - generator + Generator_ramp_limit_shut_down: + - generator + Generator_ramp_limit_start_up: + - generator + Generator_ramp_limit_up: + - generator + Generator_shut_down_cost: + - generator + Generator_stand_by_cost: + - snapshot + - generator + Generator_start_up_cost: + - generator + Generator_status_initial: + - generator + Generator_tech_capacity_weight: + - global_constraint + - generator + GlobalConstraint_constant: + - global_constraint + GlobalConstraint_sense: + - global_constraint + GlobalConstraint_type: + - global_constraint + Line_capital_cost: + - line + Line_cycle_weight: + - line + - cycle + Line_expansion_cost_weight: + - global_constraint + - line + Line_s_max_pu: + - snapshot + - line + Line_s_nom: + - line + Line_s_nom_extendable: + - line + Line_s_nom_max: + - line + Line_s_nom_min: + - line + Line_s_nom_set: + - line + Line_s_set: + - snapshot + - line + Line_tech_capacity_weight: + - global_constraint + - line + Line_volume_weight: + - global_constraint + - line + Link_capital_cost: + - link + Link_efficiency: + - link_output + Link_expansion_cost_weight: + - global_constraint + - link + Link_marginal_cost: + - snapshot + - link + Link_output_cyclic_delay: + - link_output + Link_output_delay: + - link_output + Link_p_max_pu: + - snapshot + - link + Link_p_min_pu: + - snapshot + - link + Link_p_nom: + - link + Link_p_nom_extendable: + - link + Link_p_nom_max: + - link + Link_p_nom_min: + - link + Link_p_nom_set: + - link + Link_p_set: + - snapshot + - link + Link_ramp_limit_down: + - link + Link_ramp_limit_up: + - link + Link_tech_capacity_weight: + - global_constraint + - link + Link_volume_weight: + - global_constraint + - link + Load_p_set: + - snapshot + - load + StorageUnit_capital_cost: + - storage_unit + StorageUnit_cyclic_state_of_charge: + - storage_unit + StorageUnit_efficiency_dispatch: + - storage_unit + StorageUnit_efficiency_store: + - storage_unit + StorageUnit_inflow: + - snapshot + - storage_unit + StorageUnit_marginal_cost: + - snapshot + - storage_unit + StorageUnit_marginal_cost_storage: + - snapshot + - storage_unit + StorageUnit_max_hours: + - storage_unit + StorageUnit_operational_limit_weight: + - global_constraint + - storage_unit + StorageUnit_p_max_pu: + - snapshot + - storage_unit + StorageUnit_p_min_pu: + - snapshot + - storage_unit + StorageUnit_p_nom: + - storage_unit + StorageUnit_p_nom_extendable: + - storage_unit + StorageUnit_p_nom_max: + - storage_unit + StorageUnit_p_nom_min: + - storage_unit + StorageUnit_p_nom_set: + - storage_unit + StorageUnit_p_set: + - snapshot + - storage_unit + StorageUnit_primary_energy_weight: + - global_constraint + - storage_unit + StorageUnit_retention: + - snapshot + - storage_unit + StorageUnit_spill_cost: + - snapshot + - storage_unit + StorageUnit_state_of_charge_initial: + - storage_unit + StorageUnit_state_of_charge_set: + - snapshot + - storage_unit + StorageUnit_tech_capacity_weight: + - global_constraint + - storage_unit + Store_capital_cost: + - store + Store_e_cyclic: + - store + Store_e_initial: + - store + Store_e_max_pu: + - snapshot + - store + Store_e_min_pu: + - snapshot + - store + Store_e_nom: + - store + Store_e_nom_extendable: + - store + Store_e_nom_max: + - store + Store_e_nom_min: + - store + Store_e_nom_set: + - store + Store_e_set: + - snapshot + - store + Store_marginal_cost: + - snapshot + - store + Store_marginal_cost_storage: + - snapshot + - store + Store_operational_limit_weight: + - global_constraint + - store + Store_primary_energy_weight: + - global_constraint + - store + Store_retention: + - snapshot + - store + Store_tech_capacity_weight: + - global_constraint + - store + snapshot_is_last: + - snapshot + snapshot_weightings_generators: + - snapshot + snapshot_weightings_objective: + - snapshot + snapshot_weightings_stores: + - snapshot +relations: +- Generator_bus +- Line_bus0 +- Line_bus1 +- Link_bus0 +- Link_output_bus +- Link_output_link +- Load_bus +- StorageUnit_bus +- Store_bus +variables: + Generator_n_mod: + - generator + Generator_p: + - snapshot + - generator + Generator_p_nom_ext: + - generator + Generator_shut_down: + - snapshot + - generator + Generator_start_up: + - snapshot + - generator + Generator_status: + - snapshot + - generator + Line_s: + - snapshot + - line + Line_s_nom_ext: + - line + Link_p: + - snapshot + - link + Link_p_nom_ext: + - link + StorageUnit_p_dispatch: + - snapshot + - storage_unit + StorageUnit_p_nom_ext: + - storage_unit + StorageUnit_p_store: + - snapshot + - storage_unit + StorageUnit_spill: + - snapshot + - storage_unit + StorageUnit_state_of_charge: + - snapshot + - storage_unit + Store_e: + - snapshot + - store + Store_e_nom_ext: + - store + Store_p: + - snapshot + - store diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py new file mode 100644 index 00000000..8f7ef633 --- /dev/null +++ b/tests/test_pypsa_collapses.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""`examples/pypsa.yaml` is the superset that collapses to the standard PyPSA model. + +The file always declares a `scenario` axis, a mask-only `period` axis, a `carrier` +axis and the CVaR rows. Fed one scenario, one period, all-active masks and unit +weights, every addition is a no-op and the standard model returns. The repository +runs no solver, so the reduction is guarded structurally: `tests/fixtures/ +pypsa_standard_shape.yaml` freezes the standard model's names and frames, and the +only frame change a standard row may carry is a leading `scenario`. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from math_spec import to_spec +from math_spec._yaml import read_yaml +from tests.fixtures import EXAMPLES + +STANDARD = read_yaml(Path(__file__).resolve().parent / 'fixtures' / 'pypsa_standard_shape.yaml') +ALL = to_spec(str(EXAMPLES / 'pypsa.yaml')) + +#: capacity is chosen once, before the future is known, so it spans no scenario +SCENARIO_FREE_VARIABLES = { + 'Generator_n_mod', + 'Generator_p_nom_ext', + 'Link_p_nom_ext', + 'StorageUnit_p_nom_ext', + 'Store_e_nom_ext', + 'Line_s_nom_ext', +} + + +def _dims(block: Any) -> list[str]: + return list(block.dims or []) + + +def test_every_standard_name_survives(): + sections = { + 'parameters': ALL.parameters, + 'relations': ALL.relations, + 'variables': ALL.variables, + 'expressions': ALL.expressions, + 'constraints': ALL.constraints, + } + dropped = { + f'{section}.{name}' for section, present in sections.items() for name in set(STANDARD[section]) - set(present) + } + assert not dropped, f'the unified file lost {sorted(dropped)} — it must keep every standard name' + + +def test_a_variable_gains_scenario_only_when_it_is_second_stage(): + wrong = { + name: _dims(ALL.variables[name]) + for name, dims in STANDARD['variables'].items() + if _dims(ALL.variables[name]) != (dims if name in SCENARIO_FREE_VARIABLES else ['scenario', *dims]) + } + assert not wrong, f'these variables carry the wrong frame: {wrong}' + + +def test_a_standard_constraint_changes_frame_only_by_a_leading_scenario(): + wrong = { + name: _dims(ALL.constraints[name]) + for name, dims in STANDARD['constraints'].items() + if _dims(ALL.constraints[name]) not in (dims, ['scenario', *dims]) + } + assert not wrong, f'a standard row may gain only a leading scenario; these differ: {wrong}' + + +def test_only_load_carries_scenario_among_standard_parameters(): + wrong = { + name: _dims(ALL.parameters[name]) + for name, dims in STANDARD['parameters'].items() + if _dims(ALL.parameters[name]) != (['scenario', *dims] if name == 'Load_p_set' else dims) + } + assert not wrong, f'only Load_p_set gains scenario; these differ: {wrong}' + + +def test_the_extra_axes_and_rows_are_declared(): + assert {'scenario', 'period', 'carrier'} <= set(ALL.dimensions), 'the three extra axes are declared' + assert {'CVaR_a', 'CVaR_theta', 'CVaR'} <= set(ALL.variables), 'the CVaR variables are declared' + assert {'CVaR_excess', 'CVaR_def', 'Carrier_growth_limit'} <= set(ALL.constraints), ( + 'the CVaR rows and the carrier growth limit are declared' + ) + assert 'scenario_opex' in ALL.expressions, 'the per-scenario operating cost is a named expression' + + +def test_omega_blends_expectation_and_tail_in_the_objective(): + expr = ALL.objective.expression + assert all(term in expr for term in ('CVaR_omega', 'scenario_weight', 'scenario_opex', 'CVaR')), ( + 'the objective prices expected opex by scenario weight and blends the tail by omega' + ) diff --git a/tools/gallery.py b/tools/gallery.py index 14a4a1ec..de078b7a 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -47,8 +47,6 @@ 'pypsa_quadratic.md': ROOT / 'examples' / 'pypsa_quadratic.yaml', 'pypsa_linearized_uc.md': ROOT / 'examples' / 'pypsa_linearized_uc.yaml', 'pypsa_losses.md': ROOT / 'examples' / 'pypsa_losses.yaml', - 'pypsa_stochastic.md': ROOT / 'examples' / 'pypsa_stochastic.yaml', - 'pypsa_multi_period.md': ROOT / 'examples' / 'pypsa_multi_period.yaml', } #: One PyPSA reference network per rung, run out of band with the versions From f1ea95b710a9601b04a0d8151d26db7e43ef9ff0 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 16:48:34 +0200 Subject: [PATCH 02/75] docs(pypsa): the spec covers the process and transformer components Add the Process component (a generalized multi-port converter, superset of Link) and the full-depth Transformer (a passive branch like Line, with tap ratio and a fixed phase shift in the KVL cycle) to examples/pypsa.yaml, with symbols, the regenerated gallery, rung 17 and rung 18 reference networks and oracles, and the collapse guard extended to the new standard names. --- docs/examples/pypsa.md | 596 +++++++++++- examples/pypsa.yaml | 301 +++++- examples/references/pypsa/references.json | 61 ++ examples/references/pypsa/rung_17_process.py | 42 + .../references/pypsa/rung_18_transformer.py | 63 ++ examples/symbols/pypsa.yaml | 39 + tests/fixtures/pypsa_standard_shape.yaml | 873 ++++++++++-------- tests/test_pypsa_collapses.py | 2 + 8 files changed, 1594 insertions(+), 383 deletions(-) create mode 100644 examples/references/pypsa/rung_17_process.py create mode 100644 examples/references/pypsa/rung_18_transformer.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 9e481055..b386864f 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1343,6 +1343,178 @@ def build(): +### Rung 17 — process + +A process is a generalized converter. It moves an internal power from `bus0` to +the buses it feeds, and each port draws or delivers at its own `rate`. A +non-extendable process carries a fixed capacity. An extendable one chooses its +capacity between `p_nom_min` and `p_nom_max`. A ramp limit caps the change in +internal power between snapshots. A `p_set` fixes an internal power schedule. A +`p_nom_set` fixes an extendable process's built capacity. The machinery is the +generator's and the link's, read over a converter. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Process-p`, `Process-p_nom`](#variable-domains) | done | internal power and capacity, as a link | +| [`Process-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#process-fix-p-lower) | done | rungs 1 and 3, over a converter | +| [`Process-p-ramp_limit_*`](#process-p-ramp_limit_up) | done | rung 4, on a non-committable converter | +| [`Process-p_set`](#process-p_set) | done | a fixed internal power schedule | +| [`Process-p_nom_set`](#process-p_nom_set) | done | a fixed built capacity | +| [`Bus-nodal_balance`](#bus-nodal_balance) | done | each port enters at its `rate` | +| [objective](#objective) | done | marginal cost on internal power; capital on capacity | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `9730.0`, 70 rows. + +
+The network, as PyPSA code + +`rung_17_process.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 17: process — generalized converters, one fixed and ramping, one extendable, one on a set schedule, all feeding a hub.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'hub') + n.add( + 'Process', + 'conv_fix', + bus0='north', + bus1='hub', + p_nom=50, + marginal_cost=2, + ramp_limit_up=0.3, + ramp_limit_down=0.3, + ) + n.add( + 'Process', + 'conv_ext', + bus0='south', + bus1='hub', + p_nom_extendable=True, + capital_cost=20, + p_nom_min=5, + p_nom_max=40, + marginal_cost=1, + p_nom_set=25, + ) + n.add('Process', 'conv_set', bus0='north', bus1='hub', p_nom=20, marginal_cost=3, p_set=[10, nan, nan, nan]) + n.add('Load', 'hub_load', bus='hub', p_set=[15, 20, 25, 10]) + return n +``` + +
+ + +### Rung 18 — transformer + +A transformer is a passive branch between two buses, as a line is, but its flow +follows its effective series reactance and a fixed phase shift. It obeys the +Kirchhoff voltage law (KVL) around every independent cycle, so it builds no flow +outside a mesh. A non-extendable transformer carries a fixed nominal apparent +power. An extendable one chooses it between `s_nom_min` and `s_nom_max`. An +`s_set` fixes a flow schedule. An `s_nom_set` fixes an extendable transformer's +built capacity. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Transformer-s`, `Transformer-s_nom`](#variable-domains) | done | flow and capacity, as a line | +| [`Transformer-fix-s-*`, `-ext-s-*`, `-ext-s_nom-*`](#transformer-fix-s-lower) | done | rungs 1 and 3, over a transformer | +| [`Transformer-s_set`](#transformer-s_set) | done | a fixed flow schedule | +| [`Transformer-s_nom_set`](#transformer-s_nom_set) | done | a fixed built capacity | +| [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) | done | rung 6, over `x_pu_eff` and a phase shift | +| [objective](#objective) | done | capital on capacity | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `12274.401472395122`, 106 rows. + +
+The network, as PyPSA code + +`rung_18_transformer.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 18: transformer — passive branches under KVL in a meshed triangle, one fixed and on a set flow, two extendable in parallel.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'a') + n.add('Bus', 'b') + n.add('Bus', 'c') + n.add('Generator', 'hydro18', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel18', bus='b', p_nom=80, marginal_cost=50) + n.add('Load', 'town18', bus='c', p_set=45) + n.add('Line', 'ab', bus0='a', bus1='b', carrier='AC', length=30, x=0.1, r=0.01, s_nom=60) + n.add( + 'Transformer', + 'bc', + bus0='b', + bus1='c', + x=0.1, + r=0.01, + s_nom=60, + phase_shift=10, + s_set=[16, nan, nan, nan], + ) + n.add( + 'Transformer', + 'ca', + bus0='c', + bus1='a', + x=0.15, + r=0.01, + s_nom=60, + s_nom_extendable=True, + capital_cost=10, + s_nom_min=5, + s_nom_max=40, + s_nom_set=30, + tap_ratio=1.05, + ) + n.add( + 'Transformer', + 'ca2', + bus0='c', + bus1='a', + x=0.12, + r=0.01, + s_nom=60, + s_nom_extendable=True, + capital_cost=8, + s_nom_min=5, + s_nom_max=40, + ) + return n +``` + +
+ + ### Not on a rung | PyPSA | status | note | @@ -1379,14 +1551,17 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file |---|---| | $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | | $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | -| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — network nodes | | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | | $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{R}`$ | index $`r`$ — `process_output` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — a process's ports, one label per port a process declares — PyPSA's `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number of ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | | $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | | $`\mathcal{B}`$ | index $`b`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | @@ -1428,6 +1603,21 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once | | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the horizon's end — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at the first snapshots is lost | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | +| $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | +| $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | +| $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | +| $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | +| $`\alpha`$ | `Process_rate` over $`\mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | +| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once | +| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the horizon's end — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at the first snapshots is lost | +| $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | +| $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\mathcal{J}`$ — least nominal power an extendable process may be built at | +| $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\mathcal{J}`$ — most nominal power an extendable process may be built at | +| $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\mathcal{J}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | @@ -1438,11 +1628,15 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{on}^{h}`$ | `StorageUnit_active` over $`\mathcal{T} \times \mathcal{S}`$ — whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{on}^{e}`$ | `Store_active` over $`\mathcal{T} \times \mathcal{V}`$ — whether a store stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{on}^{s}`$ | `Line_active` over $`\mathcal{T} \times \mathcal{K}`$ — whether a line stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$ — whether a process stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{on}^{\sigma}`$ | `Transformer_active` over $`\mathcal{T} \times \mathcal{M}`$ — whether a transformer stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{W}`$ | `Generator_capital_weight` over $`\mathcal{G}`$ — the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{f}`$ | `Link_capital_weight` over $`\mathcal{L}`$ — the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{h}`$ | `StorageUnit_capital_weight` over $`\mathcal{S}`$ — the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{e}`$ | `Store_capital_weight` over $`\mathcal{V}`$ — the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{s}`$ | `Line_capital_weight` over $`\mathcal{K}`$ — the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{z}`$ | `Process_capital_weight` over $`\mathcal{J}`$ — the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{W}^{\sigma}`$ | `Transformer_capital_weight` over $`\mathcal{M}`$ — the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top | @@ -1503,6 +1697,16 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | | $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | | $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | +| $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\mathcal{M}`$ — nominal apparent power | +| $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | +| $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | +| $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | +| $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | +| $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\mathcal{M}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | +| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | +| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's fixed phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; a transformer with no shift or in no cycle has no row. PyPSA also admits an optimisable phase shift, a later rung this file does not carry | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{B}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{B}`$ — which way the row binds — `<=`, `>=` or `==` | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{B}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | @@ -1522,6 +1726,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it has no row | | $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it has no row | | $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row | #### Variables @@ -1529,6 +1734,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file |---|---| | $`p`$ | `Generator_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | | $`f`$ | `Link_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | +| $`z`$ | `Process_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-p` — PyPSA's internal power `p`: a positive value drives every port at its own rate, withdrawing where the rate is negative and injecting where it is positive | | $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | | $`h^{-}`$ | `StorageUnit_p_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | | $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | @@ -1540,9 +1746,12 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | +| $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ — `Link-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | +| $`Z`$ | `Process_p_nom_ext` over $`\mathcal{J}`$ — `Process-p_nom` — nominal internal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | +| $`\Sigma`$ | `Transformer_s_nom_ext` over $`\mathcal{M}`$ — `Transformer-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`H`$ | `StorageUnit_p_nom_ext` over $`\mathcal{S}`$ — `StorageUnit-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`E`$ | `Store_e_nom_ext` over $`\mathcal{V}`$ — `Store-e_nom` — nominal capacity where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`a`$ | `CVaR_a` over $`\Xi`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | @@ -1559,9 +1768,11 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | +| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{B}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{B}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{B}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | @@ -1587,12 +1798,14 @@ objective: + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR ``` ```math -\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR ``` ### `Generator-fix-p-lower` @@ -1851,6 +2064,118 @@ Link_p_nom_set: F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{nom,set}}_{l} \text{ is defined} ``` +### `Process-fix-p-lower` + +`Process_fix_p_lower` + +```yaml +Process_fix_p_lower: + description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom +``` + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-fix-p-upper` + +`Process_fix_p_upper` + +```yaml +Process_fix_p_upper: + description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom +``` + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-ext-p-lower` + +`Process_ext_p_lower` + +```yaml +Process_ext_p_lower: + description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_ext +``` + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-ext-p-upper` + +`Process_ext_p_upper` + +```yaml +Process_ext_p_upper: + description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_ext +``` + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-ext-p_nom-lower` + +`Process_ext_p_nom_lower` + +```yaml +Process_ext_p_nom_lower: + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" + dims: [process] + where: Process_p_nom_extendable + expression: Process_p_nom_ext >= Process_p_nom_min +``` + +```math +Z_{j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} +``` + +### `Process-ext-p_nom-upper` + +`Process_ext_p_nom_upper` + +```yaml +Process_ext_p_nom_upper: + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_max + expression: Process_p_nom_ext <= Process_p_nom_max +``` + +```math +Z_{j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \text{ is defined} +``` + +### `Process-p_nom_set` + +`Process_p_nom_set` + +```yaml +Process_p_nom_set: + description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_set + expression: Process_p_nom_ext == Process_p_nom_set +``` + +```math +Z_{j} = \mathrm{z}^{\mathrm{nom,set}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{nom,set}}_{j} \text{ is defined} +``` + ### `StorageUnit-fix-p_dispatch-lower` `StorageUnit_fix_p_dispatch_lower` @@ -2521,6 +2846,134 @@ Line_s_set: s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} ``` +### `Transformer-fix-s-lower` + +`Transformer_fix_s_lower` + +```yaml +Transformer_fix_s_lower: + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom +``` + +```math +\sigma_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-fix-s-upper` + +`Transformer_fix_s_upper` + +```yaml +Transformer_fix_s_upper: + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom +``` + +```math +\sigma_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-ext-s-lower` + +`Transformer_ext_s_lower` + +```yaml +Transformer_ext_s_lower: + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom_ext +``` + +```math +\sigma_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-ext-s-upper` + +`Transformer_ext_s_upper` + +```yaml +Transformer_ext_s_upper: + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom_ext +``` + +```math +\sigma_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-ext-s_nom-lower` + +`Transformer_ext_s_nom_lower` + +```yaml +Transformer_ext_s_nom_lower: + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor" + dims: [transformer] + where: Transformer_s_nom_extendable + expression: Transformer_s_nom_ext >= Transformer_s_nom_min +``` + +```math +\Sigma_{m} \ge \underline{\sigma}^{\mathrm{nom}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} +``` + +### `Transformer-ext-s_nom-upper` + +`Transformer_ext_s_nom_upper` + +```yaml +Transformer_ext_s_nom_upper: + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" + dims: [transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_max + expression: Transformer_s_nom_ext <= Transformer_s_nom_max +``` + +```math +\Sigma_{m} \le \overline{\sigma}^{\mathrm{nom}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \overline{\sigma}^{\mathrm{nom}}_{m} \text{ is defined} +``` + +### `Transformer-s_nom_set` + +`Transformer_s_nom_set` + +```yaml +Transformer_s_nom_set: + description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_set + expression: Transformer_s_nom_ext == Transformer_s_nom_set +``` + +```math +\Sigma_{m} = \sigma^{\mathrm{nom,set}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \sigma^{\mathrm{nom,set}}_{m} \text{ is defined} +``` + +### `Transformer-s_set` + +`Transformer_s_set` + +```yaml +Transformer_s_set: + description: "`Transformer-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, transformer] + where: Transformer_s_set AND Transformer_active + expression: Transformer_s == Transformer_s_set +``` + +```math +\sigma_{\xi,t,m} = \sigma^{\mathrm{set}}_{t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \sigma^{\mathrm{set}}_{t,m} \text{ is defined} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + ### `Kirchhoff-Voltage-Law` `Kirchhoff_Voltage_Law` @@ -2530,13 +2983,18 @@ Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear - power flow physical rather than transport + power flow physical rather than transport. A transformer's flow weighs its + effective reactance, and its fixed phase shift enters the cycle sum as a + constant dims: [scenario, snapshot, cycle] - expression: sum(Line_s * Line_cycle_weight, over=line) == 0 + expression: >- + sum(Line_s * Line_cycle_weight, over=line) + + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) == 0 ``` ```math -\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} + \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` ### `Generator-p-ramp_limit_up` @@ -2622,6 +3080,41 @@ Link_p_ramp_limit_down: f_{\xi,t - 1,l} - f_{\xi,t,l} \le \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` +### `Process-p-ramp_limit_up` + +`Process_p_ramp_limit_up` + +```yaml +Process_p_ramp_limit_up: + description: >- + `Process-p-ramp_limit_up` — a process raises internal power no faster than + its limit of the build. The translated term vacates the first snapshot, + where a plain optimize builds no row either + dims: [scenario, snapshot, process] + where: Process_ramp_limit_up AND Process_active + expression: Process_p - shift(Process_p, along=snapshot, offset=1) <= Process_ramp_limit_up * Process_p_nom_effective +``` + +```math +z_{\xi,t,j} - z_{\xi,t - 1,j} \le \mathrm{ru}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p-ramp_limit_down` + +`Process_p_ramp_limit_down` + +```yaml +Process_p_ramp_limit_down: + description: "`Process-p-ramp_limit_down` — a process lowers internal power no faster than its limit of the build" + dims: [scenario, snapshot, process] + where: Process_ramp_limit_down AND Process_active + expression: shift(Process_p, along=snapshot, offset=1) - Process_p <= Process_ramp_limit_down * Process_p_nom_effective +``` + +```math +z_{\xi,t - 1,j} - z_{\xi,t,j} \le \mathrm{rd}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +``` + ### `StorageUnit-ext-p_dispatch-lower` `StorageUnit_ext_p_dispatch_lower` @@ -2955,6 +3448,22 @@ Link_p_set: f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{t,l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` +### `Process-p_set` + +`Process_p_set` + +```yaml +Process_p_set: + description: "`Process-p_set` — internal power pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, process] + where: Process_p_set AND Process_active + expression: Process_p == Process_p_set +``` + +```math +z_{\xi,t,j} = \mathrm{z}^{\mathrm{set}}_{t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{z}^{\mathrm{set}}_{t,j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +``` + ### `StorageUnit-p_set` `StorageUnit_p_set` @@ -3252,8 +3761,9 @@ Bus_nodal_balance: description: >- `Bus-nodal_balance` — what is generated at a bus, storage dispatch and stores included, less what the links take away, plus what arrives over - them after losses and any delay at every port they deliver to, meets the - load there. + them after losses and any delay at every port they deliver to, each + process port drawing or delivering at its own rate and each passive branch + carrying its flow, meets the load there. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -3263,13 +3773,16 @@ Bus_nodal_balance: + sum(Store_p, by=Store_bus, over=store, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - sum(Line_s, by=Line_bus0, over=line, into=bus) + sum(Line_s, by=Line_bus1, over=line, into=bus) + - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` @@ -3436,6 +3949,21 @@ Link_p_nom_effective: \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} = \begin{cases} F_{l} & \text{if } \mathrm{ext}^{f}_{l} \\ \mathrm{f}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} ``` +### `Process_p_nom_effective` + +```yaml +Process_p_nom_effective: + description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [process] + cases: + extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } + otherwise: Process_p_nom +``` + +```math +\widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} = \begin{cases} Z_{j} & \text{if } \mathrm{ext}^{z}_{j} \\ \mathrm{z}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + ### `StorageUnit_charge_carried_in` ```yaml @@ -3507,6 +4035,28 @@ Link_output_arrival: \overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` +### `Process_output_arrival` + +```yaml +Process_output_arrival: + description: >- + what a process transfers at a port at a snapshot — its internal power + times the port's rate, delayed by the port's `delay`; where the port is + `cyclic_delay` the delayed transfer wraps from the horizon's end, and where + it is not the energy still in transit at the first snapshots is lost. A + port that does not delay (`delay` zero) transfers at once, cyclic or not + dims: [scenario, snapshot, process_output] + cases: + wrapping: + when: Process_output_cyclic_delay + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap') + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0) +``` + +```math +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +``` + ### `primary_energy` ```yaml @@ -3582,10 +4132,11 @@ tech_capacity_expansion: + sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + + sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) ``` ```math -\mathit{tech\_capacity\_expansion}_{b} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{b,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{b,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{b,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{b,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{b,v} \qquad \forall\, b \in \mathcal{B} +\mathit{tech\_capacity\_expansion}_{b} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{b,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{b,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{b,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{b,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{b,v} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{b,j} \qquad \forall\, b \in \mathcal{B} ``` ### `scenario_opex` @@ -3596,6 +4147,7 @@ scenario_opex: expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) @@ -3607,7 +4159,7 @@ scenario_opex: ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` #### Variable domains @@ -3624,6 +4176,12 @@ p_{\xi,t,g} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g f_{\xi,t,l} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{on}^{f}_{t,l} ``` +**`Process_p`** + +```math +z_{\xi,t,j} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{on}^{z}_{t,j} +``` + **`StorageUnit_p_dispatch`** ```math @@ -3690,6 +4248,12 @@ u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \ s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{on}^{s}_{t,k} ``` +**`Transformer_s`** + +```math +\sigma_{\xi,t,m} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{on}^{\sigma}_{t,m} +``` + **`Line_s_nom_ext`** ```math @@ -3708,6 +4272,18 @@ P_{g} \in \mathbb{R} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} F_{l} \in \mathbb{R} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} ``` +**`Process_p_nom_ext`** + +```math +Z_{j} \in \mathbb{R} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} +``` + +**`Transformer_s_nom_ext`** + +```math +\Sigma_{m} \in \mathbb{R} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} +``` + **`StorageUnit_p_nom_ext`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index f4640fb7..4e6f7a2c 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -30,6 +30,13 @@ dimensions: a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + process_output: + description: >- + a process's ports, one label per port a process declares — PyPSA's + `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number + of ports is one term in the balance, data prep load: description: demands, each on one bus storage_unit: @@ -38,6 +45,8 @@ dimensions: description: pure energy stores, each on one bus line: description: passive branches, each between two buses, their flow set by impedance + transformer: + description: passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift cycle: description: independent cycles of the passive network graph — the cycle basis, data prep global_constraint: @@ -173,6 +182,65 @@ parameters: Link_marginal_cost: description: cost of one unit of flow dims: [snapshot, link] + Process_p_nom: + description: nominal internal power + dims: [process] + Process_p_nom_extendable: + description: whether the nominal internal power is a decision + dims: [process] + dtype: bool + Process_p_min_pu: + description: least internal power, per unit of nominal power — negative for a process that runs both ways + dims: [snapshot, process] + Process_p_max_pu: + description: most internal power, per unit of nominal power + dims: [snapshot, process] + Process_rate: + description: >- + the energy a port draws or delivers per unit of internal power, PyPSA's + `rate0`, `rate1`, … read long — negative where the port withdraws, + positive where it injects; a link is a process whose `bus0` rate is minus + one and whose output rates are its efficiencies + dims: [process_output] + Process_output_delay: + description: >- + snapshots a port's transfer lags its process's internal power — PyPSA's + `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` + units, which the file states as whole snapshots; zero for a port that + transfers at once + dims: [process_output] + dtype: int + Process_output_cyclic_delay: + description: >- + whether a delayed port's transfer wraps from the horizon's end — PyPSA's + `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still + in transit at the first snapshots is lost + dims: [process_output] + dtype: bool + Process_marginal_cost: + description: cost of one unit of internal power + dims: [snapshot, process] + Process_ramp_limit_up: + description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit + dims: [process] + Process_ramp_limit_down: + description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit + dims: [process] + Process_p_set: + description: a given internal power schedule; a process without one has no row here + dims: [snapshot, process] + Process_p_nom_min: + description: least nominal power an extendable process may be built at + dims: [process] + Process_p_nom_max: + description: most nominal power an extendable process may be built at + dims: [process] + Process_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [process] + Process_p_nom_set: + description: a given nominal power for an extendable process; one without a value has no row here + dims: [process] Load_p_set: description: demand dims: [scenario, snapshot, load] @@ -208,6 +276,14 @@ parameters: description: whether a line stands in a snapshot's period — PyPSA's `active`, data prep dims: [snapshot, line] dtype: bool + Process_active: + description: whether a process stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, process] + dtype: bool + Transformer_active: + description: whether a transformer stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, transformer] + dtype: bool Generator_capital_weight: description: the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep dims: [generator] @@ -223,6 +299,12 @@ parameters: Line_capital_weight: description: the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep dims: [line] + Process_capital_weight: + description: the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [process] + Transformer_capital_weight: + description: the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [transformer] Generator_first_active: description: one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep dims: [period, generator] @@ -410,6 +492,44 @@ parameters: the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row dims: [line, cycle] + Transformer_s_nom: + description: nominal apparent power + dims: [transformer] + Transformer_s_nom_extendable: + description: whether the nominal apparent power is a decision + dims: [transformer] + dtype: bool + Transformer_s_max_pu: + description: most flow either way, per unit of nominal apparent power + dims: [snapshot, transformer] + Transformer_s_nom_min: + description: least nominal apparent power an extendable transformer may be built at + dims: [transformer] + Transformer_s_nom_max: + description: most nominal apparent power an extendable transformer may be built at + dims: [transformer] + Transformer_capital_cost: + description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [transformer] + Transformer_s_nom_set: + description: a given nominal apparent power for an extendable transformer; one without a value has no row here + dims: [transformer] + Transformer_s_set: + description: a given flow schedule; a transformer without one has no row here + dims: [snapshot, transformer] + Transformer_cycle_weight: + description: >- + the transformer's effective series reactance, `x` times its tap ratio, + signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle + basis, data prep; a transformer in no cycle has no row + dims: [transformer, cycle] + Transformer_phase_shift_weight: + description: >- + the transformer's fixed phase shift in radians, signed by its orientation + in the cycle — a constant added to the cycle sum, data prep; a transformer + with no shift or in no cycle has no row. PyPSA also admits an optimisable + phase shift, a later rung this file does not carry + dims: [transformer, cycle] GlobalConstraint_type: description: >- which formula the row takes — `primary_energy`, `operational_limit`, @@ -482,6 +602,9 @@ parameters: Store_tech_capacity_weight: description: one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row dims: [global_constraint, store] + Process_tech_capacity_weight: + description: one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row + dims: [global_constraint, process] relations: snapshot_period: @@ -511,6 +634,17 @@ relations: relation, so the file states any number of them key: link_output values: bus + Process_output_process: + description: the process a port belongs to + key: process_output + values: process + Process_output_bus: + description: >- + the bus a port draws from or delivers to — PyPSA's `bus0`, `bus1`, … + columns. A process of three ports is three labels here rather than a + third relation, so the file states any number of them + key: process_output + values: bus Load_bus: description: the bus a load sits on key: load @@ -531,6 +665,14 @@ relations: description: the bus a store sits on key: store values: bus + Transformer_bus0: + description: the bus a transformer's flow is measured at + key: transformer + values: bus + Transformer_bus1: + description: the bus at a transformer's other end + key: transformer + values: bus variables: Generator_p: @@ -544,6 +686,13 @@ variables: output ports deliver to dims: [scenario, snapshot, link] where: Link_active + Process_p: + description: >- + `Process-p` — PyPSA's internal power `p`: a positive value drives every + port at its own rate, withdrawing where the rate is negative and injecting + where it is positive + dims: [scenario, snapshot, process] + where: Process_active StorageUnit_p_dispatch: description: "`StorageUnit-p_dispatch` — power delivered to the bus" dims: [scenario, snapshot, storage_unit] @@ -612,6 +761,13 @@ variables: positive value withdraws there and injects at `Line_bus1`, lossless dims: [scenario, snapshot, line] where: Line_active + Transformer_s: + description: >- + `Transformer-s` — PyPSA's `p0`, the flow measured at the + `Transformer_bus0` end: a positive value withdraws there and injects at + `Transformer_bus1`, lossless + dims: [scenario, snapshot, transformer] + where: Transformer_active Line_s_nom_ext: description: >- `Line-s_nom` — nominal apparent power where it is a decision; the @@ -630,6 +786,18 @@ variables: the same PyPSA name carries the fixed regime dims: [link] where: Link_p_nom_extendable + Process_p_nom_ext: + description: >- + `Process-p_nom` — nominal internal power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [process] + where: Process_p_nom_extendable + Transformer_s_nom_ext: + description: >- + `Transformer-s_nom` — nominal apparent power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [transformer] + where: Transformer_s_nom_extendable StorageUnit_p_nom_ext: description: >- `StorageUnit-p_nom` — nominal power where it is a decision; the @@ -712,6 +880,12 @@ expressions: cases: extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } otherwise: Link_p_nom + Process_p_nom_effective: + description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [process] + cases: + extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } + otherwise: Process_p_nom StorageUnit_charge_carried_in: description: >- the charge a unit opens a snapshot with — its last snapshot's less @@ -756,6 +930,19 @@ expressions: when: Link_output_cyclic_delay expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap') otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0) + Process_output_arrival: + description: >- + what a process transfers at a port at a snapshot — its internal power + times the port's rate, delayed by the port's `delay`; where the port is + `cyclic_delay` the delayed transfer wraps from the horizon's end, and where + it is not the energy still in transit at the first snapshots is lost. A + port that does not delay (`delay` zero) transfers at once, cyclic or not + dims: [scenario, snapshot, process_output] + cases: + wrapping: + when: Process_output_cyclic_delay + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap') + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0) primary_energy: description: >- what a `primary_energy` row totals — weighted generator energy, less @@ -792,11 +979,13 @@ expressions: + sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + + sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) scenario_opex: description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) @@ -887,6 +1076,41 @@ constraints: dims: [link] where: Link_p_nom_extendable AND Link_p_nom_set expression: Link_p_nom_ext == Link_p_nom_set + Process_fix_p_lower: + description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom + Process_fix_p_upper: + description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom + Process_ext_p_lower: + description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_ext + Process_ext_p_upper: + description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + Process_ext_p_nom_lower: + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" + dims: [process] + where: Process_p_nom_extendable + expression: Process_p_nom_ext >= Process_p_nom_min + Process_ext_p_nom_upper: + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_max + expression: Process_p_nom_ext <= Process_p_nom_max + Process_p_nom_set: + description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_set + expression: Process_p_nom_ext == Process_p_nom_set StorageUnit_fix_p_dispatch_lower: description: "`StorageUnit-fix-p_dispatch-lower` — dispatch is non-negative" dims: [scenario, snapshot, storage_unit] @@ -1139,13 +1363,58 @@ constraints: dims: [scenario, snapshot, line] where: Line_s_set AND Line_active expression: Line_s == Line_s_set + Transformer_fix_s_lower: + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper: + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower: + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper: + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_nom_lower: + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor" + dims: [transformer] + where: Transformer_s_nom_extendable + expression: Transformer_s_nom_ext >= Transformer_s_nom_min + Transformer_ext_s_nom_upper: + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" + dims: [transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_max + expression: Transformer_s_nom_ext <= Transformer_s_nom_max + Transformer_s_nom_set: + description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_set + expression: Transformer_s_nom_ext == Transformer_s_nom_set + Transformer_s_set: + description: "`Transformer-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, transformer] + where: Transformer_s_set AND Transformer_active + expression: Transformer_s == Transformer_s_set Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear - power flow physical rather than transport + power flow physical rather than transport. A transformer's flow weighs its + effective reactance, and its fixed phase shift enters the cycle sum as a + constant dims: [scenario, snapshot, cycle] - expression: sum(Line_s * Line_cycle_weight, over=line) == 0 + expression: >- + sum(Line_s * Line_cycle_weight, over=line) + + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) == 0 Generator_p_ramp_limit_up: description: >- `Generator-p-ramp_limit_up` — a generator raises output no faster than @@ -1185,6 +1454,19 @@ constraints: dims: [scenario, snapshot, link] where: Link_ramp_limit_down AND Link_active expression: shift(Link_p, along=snapshot, offset=1) - Link_p <= Link_ramp_limit_down * Link_p_nom_effective + Process_p_ramp_limit_up: + description: >- + `Process-p-ramp_limit_up` — a process raises internal power no faster than + its limit of the build. The translated term vacates the first snapshot, + where a plain optimize builds no row either + dims: [scenario, snapshot, process] + where: Process_ramp_limit_up AND Process_active + expression: Process_p - shift(Process_p, along=snapshot, offset=1) <= Process_ramp_limit_up * Process_p_nom_effective + Process_p_ramp_limit_down: + description: "`Process-p-ramp_limit_down` — a process lowers internal power no faster than its limit of the build" + dims: [scenario, snapshot, process] + where: Process_ramp_limit_down AND Process_active + expression: shift(Process_p, along=snapshot, offset=1) - Process_p <= Process_ramp_limit_down * Process_p_nom_effective StorageUnit_ext_p_dispatch_lower: description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" dims: [scenario, snapshot, storage_unit] @@ -1298,6 +1580,11 @@ constraints: dims: [scenario, snapshot, link] where: Link_p_set AND Link_active expression: Link_p == Link_p_set + Process_p_set: + description: "`Process-p_set` — internal power pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, process] + where: Process_p_set AND Process_active + expression: Process_p == Process_p_set StorageUnit_p_set: description: "`StorageUnit-p_set` — net dispatch pinned to the given schedule, wherever one is given" dims: [scenario, snapshot, storage_unit] @@ -1392,8 +1679,9 @@ constraints: description: >- `Bus-nodal_balance` — what is generated at a bus, storage dispatch and stores included, less what the links take away, plus what arrives over - them after losses and any delay at every port they deliver to, meets the - load there. + them after losses and any delay at every port they deliver to, each + process port drawing or delivering at its own rate and each passive branch + carrying its flow, meets the load there. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -1403,8 +1691,11 @@ constraints: + sum(Store_p, by=Store_bus, over=store, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - sum(Line_s, by=Line_bus0, over=line, into=bus) + sum(Line_s, by=Line_bus1, over=line, into=bus) + - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) Carrier_growth_limit: description: >- @@ -1436,5 +1727,7 @@ objective: + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index dc60e459..ac9fd5dd 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -582,5 +582,66 @@ "Link-fix-p-lower": 8, "Link-fix-p-upper": 8 } + }, + "rung_17_process": { + "columns": { + "Generator-p": 8, + "Link-p": 4, + "Process-p": 12, + "Process-p_nom": 1 + }, + "global_constraints": {}, + "objective": 9730.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Process-ext-p-lower": 4, + "Process-ext-p-upper": 4, + "Process-ext-p_nom-lower": 1, + "Process-ext-p_nom-upper": 1, + "Process-fix-p-lower": 8, + "Process-fix-p-upper": 8, + "Process-p-ramp_limit_down": 3, + "Process-p-ramp_limit_up": 3, + "Process-p_nom_set": 1, + "Process-p_set": 1 + } + }, + "rung_18_transformer": { + "columns": { + "Generator-p": 16, + "Line-s": 4, + "Link-p": 4, + "Transformer-s": 12, + "Transformer-s_nom": 2, + "objective_constant": 1 + }, + "global_constraints": {}, + "objective": 12274.401472395122, + "objective_constant": 1080.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Kirchhoff-Voltage-Law": 8, + "Line-fix-s-lower": 4, + "Line-fix-s-upper": 4, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Transformer-ext-s-lower": 8, + "Transformer-ext-s-upper": 8, + "Transformer-ext-s_nom-lower": 2, + "Transformer-ext-s_nom-upper": 2, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-upper": 4, + "Transformer-s_nom_set": 1, + "Transformer-s_set": 1 + } } } diff --git a/examples/references/pypsa/rung_17_process.py b/examples/references/pypsa/rung_17_process.py new file mode 100644 index 00000000..273df6ea --- /dev/null +++ b/examples/references/pypsa/rung_17_process.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 17: process — generalized converters, one fixed and ramping, one extendable, one on a set schedule, all feeding a hub.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'hub') + n.add( + 'Process', + 'conv_fix', + bus0='north', + bus1='hub', + p_nom=50, + marginal_cost=2, + ramp_limit_up=0.3, + ramp_limit_down=0.3, + ) + n.add( + 'Process', + 'conv_ext', + bus0='south', + bus1='hub', + p_nom_extendable=True, + capital_cost=20, + p_nom_min=5, + p_nom_max=40, + marginal_cost=1, + p_nom_set=25, + ) + n.add('Process', 'conv_set', bus0='north', bus1='hub', p_nom=20, marginal_cost=3, p_set=[10, nan, nan, nan]) + n.add('Load', 'hub_load', bus='hub', p_set=[15, 20, 25, 10]) + return n diff --git a/examples/references/pypsa/rung_18_transformer.py b/examples/references/pypsa/rung_18_transformer.py new file mode 100644 index 00000000..9ad1f59f --- /dev/null +++ b/examples/references/pypsa/rung_18_transformer.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 18: transformer — passive branches under KVL in a meshed triangle, one fixed and on a set flow, two extendable in parallel.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'a') + n.add('Bus', 'b') + n.add('Bus', 'c') + n.add('Generator', 'hydro18', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel18', bus='b', p_nom=80, marginal_cost=50) + n.add('Load', 'town18', bus='c', p_set=45) + n.add('Line', 'ab', bus0='a', bus1='b', carrier='AC', length=30, x=0.1, r=0.01, s_nom=60) + n.add( + 'Transformer', + 'bc', + bus0='b', + bus1='c', + x=0.1, + r=0.01, + s_nom=60, + phase_shift=10, + s_set=[16, nan, nan, nan], + ) + n.add( + 'Transformer', + 'ca', + bus0='c', + bus1='a', + x=0.15, + r=0.01, + s_nom=60, + s_nom_extendable=True, + capital_cost=10, + s_nom_min=5, + s_nom_max=40, + s_nom_set=30, + tap_ratio=1.05, + ) + n.add( + 'Transformer', + 'ca2', + bus0='c', + bus1='a', + x=0.12, + r=0.01, + s_nom=60, + s_nom_extendable=True, + capital_cost=8, + s_nom_min=5, + s_nom_max=40, + ) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 9c871565..07f01892 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -15,6 +15,9 @@ dimensions: storage_unit: { index: s, set: '\mathcal{S}' } store: { index: v, set: '\mathcal{V}' } line: { index: k, set: '\mathcal{K}' } + transformer: { index: m, set: '\mathcal{M}' } + process: { index: j, set: '\mathcal{J}' } + process_output: { index: r, set: '\mathcal{R}' } cycle: { index: c, set: '\mathcal{C}' } scenario: { index: '\xi', set: '\Xi' } period: { index: y, set: '\mathcal{Y}' } @@ -82,6 +85,25 @@ names: Link_p_nom_max: '\overline{\mathrm{f}}^{\mathrm{nom}}' Link_capital_cost: '\mathrm{c}^{\mathrm{cap},f}' Link_p_nom_set: '\mathrm{f}^{\mathrm{nom,set}}' + Process_p: z + Process_p_nom: '\mathrm{z}^{\mathrm{nom}}' + Process_p_nom_extendable: '\mathrm{ext}^{z}' + Process_p_min_pu: '\underline{\mathrm{z}}' + Process_p_max_pu: '\overline{\mathrm{z}}' + Process_rate: '\alpha' + Process_output_delay: '\mathrm{d}^{z}' + Process_output_cyclic_delay: '\mathrm{cyc}^{z}' + Process_marginal_cost: '\mathrm{c}^{z}' + Process_ramp_limit_up: '\mathrm{ru}^{z}' + Process_ramp_limit_down: '\mathrm{rd}^{z}' + Process_p_set: '\mathrm{z}^{\mathrm{set}}' + Process_p_nom_min: '\underline{\mathrm{z}}^{\mathrm{nom}}' + Process_p_nom_max: '\overline{\mathrm{z}}^{\mathrm{nom}}' + Process_capital_cost: '\mathrm{c}^{\mathrm{cap},z}' + Process_p_nom_set: '\mathrm{z}^{\mathrm{nom,set}}' + Process_p_nom_ext: "Z" + Process_p_nom_effective: '\widetilde{\mathrm{z}}^{\mathrm{nom}}' + Process_output_arrival: '\overrightarrow{z}' StorageUnit_p_nom_ext: "H" StorageUnit_p_nom_min: '\underline{\mathrm{h}}^{\mathrm{nom}}' StorageUnit_p_nom_max: '\overline{\mathrm{h}}^{\mathrm{nom}}' @@ -103,6 +125,18 @@ names: Line_s_nom_set: '\mathrm{s}^{\mathrm{nom,set}}' Line_s_set: '\mathrm{s}^{\mathrm{set}}' Line_cycle_weight: '\mathrm{x}' + Transformer_s: '\sigma' + Transformer_s_nom_ext: '\Sigma' + Transformer_s_nom: '\sigma^{\mathrm{nom}}' + Transformer_s_nom_extendable: '\mathrm{ext}^{\sigma}' + Transformer_s_max_pu: '\overline{\sigma}' + Transformer_s_nom_min: '\underline{\sigma}^{\mathrm{nom}}' + Transformer_s_nom_max: '\overline{\sigma}^{\mathrm{nom}}' + Transformer_capital_cost: '\mathrm{c}^{\mathrm{cap},\sigma}' + Transformer_s_nom_set: '\sigma^{\mathrm{nom,set}}' + Transformer_s_set: '\sigma^{\mathrm{set}}' + Transformer_cycle_weight: '\mathrm{x}^{\sigma}' + Transformer_phase_shift_weight: '\vartheta' GlobalConstraint_type: '\mathrm{type}' GlobalConstraint_sense: '\mathrm{sense}' GlobalConstraint_constant: '\mathrm{K}' @@ -120,6 +154,7 @@ names: Generator_tech_capacity_weight: '\mathrm{m}' Link_tech_capacity_weight: '\mathrm{m}^{f}' Line_tech_capacity_weight: '\mathrm{m}^{l}' + Process_tech_capacity_weight: '\mathrm{m}^{z}' StorageUnit_tech_capacity_weight: '\mathrm{m}^{h}' Store_tech_capacity_weight: '\mathrm{m}^{e}' StorageUnit_p_dispatch: "h^{+}" @@ -168,11 +203,15 @@ names: StorageUnit_active: '\mathrm{on}^{h}' Store_active: '\mathrm{on}^{e}' Line_active: '\mathrm{on}^{s}' + Process_active: '\mathrm{on}^{z}' + Transformer_active: '\mathrm{on}^{\sigma}' Generator_capital_weight: '\mathrm{W}' Link_capital_weight: '\mathrm{W}^{f}' StorageUnit_capital_weight: '\mathrm{W}^{h}' Store_capital_weight: '\mathrm{W}^{e}' Line_capital_weight: '\mathrm{W}^{s}' + Process_capital_weight: '\mathrm{W}^{z}' + Transformer_capital_weight: '\mathrm{W}^{\sigma}' Generator_first_active: '\mathrm{new}' Carrier_max_growth: '\overline{\Delta}' Carrier_max_relative_growth: '\mathrm{r}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index 2c6ac7f8..cde00bcb 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -8,600 +8,735 @@ constraints: Bus_nodal_balance: - - snapshot - - bus + - snapshot + - bus Generator_com_down_time: - - snapshot - - generator + - snapshot + - generator Generator_com_ext_p_lower: - - snapshot - - generator + - snapshot + - generator Generator_com_ext_p_lower_nonneg: - - snapshot - - generator + - snapshot + - generator Generator_com_ext_p_upper_big_m: - - snapshot - - generator + - snapshot + - generator Generator_com_ext_p_upper_cap: - - snapshot - - generator + - snapshot + - generator Generator_com_mod_p_lower: - - snapshot - - generator + - snapshot + - generator Generator_com_mod_p_upper: - - snapshot - - generator + - snapshot + - generator Generator_com_p_lower: - - snapshot - - generator + - snapshot + - generator Generator_com_p_upper: - - snapshot - - generator + - snapshot + - generator Generator_com_status_must_stay_up: - - snapshot - - generator + - snapshot + - generator Generator_com_transition_shut_down: - - snapshot - - generator + - snapshot + - generator Generator_com_transition_start_up: - - snapshot - - generator + - snapshot + - generator Generator_com_up_time: - - snapshot - - generator + - snapshot + - generator Generator_e_sum_max: - - generator + - generator Generator_e_sum_min: - - generator + - generator Generator_ext_p_lower: - - snapshot - - generator + - snapshot + - generator Generator_ext_p_nom_lower: - - generator + - generator Generator_ext_p_nom_upper: - - generator + - generator Generator_ext_p_upper: - - snapshot - - generator + - snapshot + - generator Generator_fix_p_lower: - - snapshot - - generator + - snapshot + - generator Generator_fix_p_upper: - - snapshot - - generator + - snapshot + - generator Generator_p_nom_modularity: - - generator + - generator Generator_p_nom_set: - - generator + - generator Generator_p_ramp_limit_down: - - snapshot - - generator + - snapshot + - generator Generator_p_ramp_limit_down_run_big_m: - - snapshot - - generator + - snapshot + - generator Generator_p_ramp_limit_down_shut_big_m: - - snapshot - - generator + - snapshot + - generator Generator_p_ramp_limit_up: - - snapshot - - generator + - snapshot + - generator Generator_p_ramp_limit_up_run_big_m: - - snapshot - - generator + - snapshot + - generator Generator_p_ramp_limit_up_start_big_m: - - snapshot - - generator + - snapshot + - generator Generator_p_set: - - snapshot - - generator + - snapshot + - generator Generator_shut_down_p_fixed_upper: - - snapshot - - generator + - snapshot + - generator Generator_shut_down_p_nom_variable_upper: - - snapshot - - generator + - snapshot + - generator Generator_start_up_p_fixed_upper: - - snapshot - - generator + - snapshot + - generator Generator_start_up_p_nom_variable_upper: - - snapshot - - generator + - snapshot + - generator Generator_status_p_fixed_upper: - - snapshot - - generator + - snapshot + - generator Generator_status_p_nom_variable_upper: - - snapshot - - generator + - snapshot + - generator GlobalConstraint_operational_limit_eq: - - global_constraint + - global_constraint GlobalConstraint_operational_limit_lb: - - global_constraint + - global_constraint GlobalConstraint_operational_limit_ub: - - global_constraint + - global_constraint GlobalConstraint_primary_energy_eq: - - global_constraint + - global_constraint GlobalConstraint_primary_energy_lb: - - global_constraint + - global_constraint GlobalConstraint_primary_energy_ub: - - global_constraint + - global_constraint GlobalConstraint_tech_capacity_expansion_limit_eq: - - global_constraint + - global_constraint GlobalConstraint_tech_capacity_expansion_limit_lb: - - global_constraint + - global_constraint GlobalConstraint_tech_capacity_expansion_limit_ub: - - global_constraint + - global_constraint GlobalConstraint_transmission_expansion_cost_limit_eq: - - global_constraint + - global_constraint GlobalConstraint_transmission_expansion_cost_limit_lb: - - global_constraint + - global_constraint GlobalConstraint_transmission_expansion_cost_limit_ub: - - global_constraint + - global_constraint GlobalConstraint_transmission_volume_expansion_limit_eq: - - global_constraint + - global_constraint GlobalConstraint_transmission_volume_expansion_limit_lb: - - global_constraint + - global_constraint GlobalConstraint_transmission_volume_expansion_limit_ub: - - global_constraint + - global_constraint Kirchhoff_Voltage_Law: - - snapshot - - cycle + - snapshot + - cycle Line_ext_s_lower: - - snapshot - - line + - snapshot + - line Line_ext_s_nom_lower: - - line + - line Line_ext_s_nom_upper: - - line + - line Line_ext_s_upper: - - snapshot - - line + - snapshot + - line Line_fix_s_lower: - - snapshot - - line + - snapshot + - line Line_fix_s_upper: - - snapshot - - line + - snapshot + - line Line_s_nom_set: - - line + - line Line_s_set: - - snapshot - - line + - snapshot + - line Link_ext_p_lower: - - snapshot - - link + - snapshot + - link Link_ext_p_nom_lower: - - link + - link Link_ext_p_nom_upper: - - link + - link Link_ext_p_upper: - - snapshot - - link + - snapshot + - link Link_fix_p_lower: - - snapshot - - link + - snapshot + - link Link_fix_p_upper: - - snapshot - - link + - snapshot + - link Link_p_nom_set: - - link + - link Link_p_ramp_limit_down: - - snapshot - - link + - snapshot + - link Link_p_ramp_limit_up: - - snapshot - - link + - snapshot + - link Link_p_set: - - snapshot - - link + - snapshot + - link + Process_ext_p_lower: + - snapshot + - process + Process_ext_p_nom_lower: + - process + Process_ext_p_nom_upper: + - process + Process_ext_p_upper: + - snapshot + - process + Process_fix_p_lower: + - snapshot + - process + Process_fix_p_upper: + - snapshot + - process + Process_p_nom_set: + - process + Process_p_ramp_limit_down: + - snapshot + - process + Process_p_ramp_limit_up: + - snapshot + - process + Process_p_set: + - snapshot + - process StorageUnit_energy_balance: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_p_dispatch_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_p_dispatch_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_p_nom_lower: - - storage_unit + - storage_unit StorageUnit_ext_p_nom_upper: - - storage_unit + - storage_unit StorageUnit_ext_p_store_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_p_store_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_state_of_charge_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_ext_state_of_charge_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_p_dispatch_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_p_dispatch_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_p_store_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_p_store_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_state_of_charge_lower: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_fix_state_of_charge_upper: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_p_nom_set: - - storage_unit + - storage_unit StorageUnit_p_set: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_state_of_charge_set: - - snapshot - - storage_unit + - snapshot + - storage_unit Store_e_nom_set: - - store + - store Store_e_set: - - snapshot - - store + - snapshot + - store Store_energy_balance: - - snapshot - - store + - snapshot + - store Store_ext_e_lower: - - snapshot - - store + - snapshot + - store Store_ext_e_nom_lower: - - store + - store Store_ext_e_nom_upper: - - store + - store Store_ext_e_upper: - - snapshot - - store + - snapshot + - store Store_fix_e_lower: - - snapshot - - store + - snapshot + - store Store_fix_e_upper: - - snapshot - - store + - snapshot + - store + Transformer_ext_s_lower: + - snapshot + - transformer + Transformer_ext_s_nom_lower: + - transformer + Transformer_ext_s_nom_upper: + - transformer + Transformer_ext_s_upper: + - snapshot + - transformer + Transformer_fix_s_lower: + - snapshot + - transformer + Transformer_fix_s_upper: + - snapshot + - transformer + Transformer_s_nom_set: + - transformer + Transformer_s_set: + - snapshot + - transformer expressions: -- Generator_p_nom_effective -- Generator_previous_p -- Generator_previous_status -- Generator_ramp_down_allowance -- Generator_ramp_up_allowance -- Link_output_arrival -- Link_p_nom_effective -- StorageUnit_charge_carried_in -- Store_energy_carried_in -- operational_limit -- primary_energy -- tech_capacity_expansion -- transmission_expansion_cost -- transmission_volume_expansion + - Generator_p_nom_effective + - Generator_previous_p + - Generator_previous_status + - Generator_ramp_down_allowance + - Generator_ramp_up_allowance + - Link_output_arrival + - Link_p_nom_effective + - Process_output_arrival + - Process_p_nom_effective + - StorageUnit_charge_carried_in + - Store_energy_carried_in + - operational_limit + - primary_energy + - tech_capacity_expansion + - transmission_expansion_cost + - transmission_volume_expansion parameters: Generator_big_m: - - generator + - generator Generator_capital_cost: - - generator + - generator Generator_committable: - - generator + - generator Generator_e_sum_max: - - generator + - generator Generator_e_sum_min: - - generator + - generator Generator_marginal_cost: - - snapshot - - generator + - snapshot + - generator Generator_min_down_time: - - generator + - generator Generator_min_up_time: - - generator + - generator Generator_modules_installed: - - generator + - generator Generator_must_stay_up: - - snapshot - - generator + - snapshot + - generator Generator_operational_limit_weight: - - global_constraint - - generator + - global_constraint + - generator Generator_p_max_pu: - - snapshot - - generator + - snapshot + - generator Generator_p_min_pu: - - snapshot - - generator + - snapshot + - generator Generator_p_min_pu_nonneg: - - generator + - generator Generator_p_nom: - - generator + - generator Generator_p_nom_extendable: - - generator + - generator Generator_p_nom_max: - - generator + - generator Generator_p_nom_min: - - generator + - generator Generator_p_nom_mod: - - generator + - generator Generator_p_nom_set: - - generator + - generator Generator_p_set: - - snapshot - - generator + - snapshot + - generator Generator_primary_energy_weight: - - global_constraint - - generator + - global_constraint + - generator Generator_ramp_limit_down: - - generator + - generator Generator_ramp_limit_shut_down: - - generator + - generator Generator_ramp_limit_start_up: - - generator + - generator Generator_ramp_limit_up: - - generator + - generator Generator_shut_down_cost: - - generator + - generator Generator_stand_by_cost: - - snapshot - - generator + - snapshot + - generator Generator_start_up_cost: - - generator + - generator Generator_status_initial: - - generator + - generator Generator_tech_capacity_weight: - - global_constraint - - generator + - global_constraint + - generator GlobalConstraint_constant: - - global_constraint + - global_constraint GlobalConstraint_sense: - - global_constraint + - global_constraint GlobalConstraint_type: - - global_constraint + - global_constraint Line_capital_cost: - - line + - line Line_cycle_weight: - - line - - cycle + - line + - cycle Line_expansion_cost_weight: - - global_constraint - - line + - global_constraint + - line Line_s_max_pu: - - snapshot - - line + - snapshot + - line Line_s_nom: - - line + - line Line_s_nom_extendable: - - line + - line Line_s_nom_max: - - line + - line Line_s_nom_min: - - line + - line Line_s_nom_set: - - line + - line Line_s_set: - - snapshot - - line + - snapshot + - line Line_tech_capacity_weight: - - global_constraint - - line + - global_constraint + - line Line_volume_weight: - - global_constraint - - line + - global_constraint + - line Link_capital_cost: - - link + - link Link_efficiency: - - link_output + - link_output Link_expansion_cost_weight: - - global_constraint - - link + - global_constraint + - link Link_marginal_cost: - - snapshot - - link + - snapshot + - link Link_output_cyclic_delay: - - link_output + - link_output Link_output_delay: - - link_output + - link_output Link_p_max_pu: - - snapshot - - link + - snapshot + - link Link_p_min_pu: - - snapshot - - link + - snapshot + - link Link_p_nom: - - link + - link Link_p_nom_extendable: - - link + - link Link_p_nom_max: - - link + - link Link_p_nom_min: - - link + - link Link_p_nom_set: - - link + - link Link_p_set: - - snapshot - - link + - snapshot + - link Link_ramp_limit_down: - - link + - link Link_ramp_limit_up: - - link + - link Link_tech_capacity_weight: - - global_constraint - - link + - global_constraint + - link Link_volume_weight: - - global_constraint - - link + - global_constraint + - link Load_p_set: - - snapshot - - load + - snapshot + - load + Process_active: + - snapshot + - process + Process_capital_cost: + - process + Process_capital_weight: + - process + Process_marginal_cost: + - snapshot + - process + Process_output_cyclic_delay: + - process_output + Process_output_delay: + - process_output + Process_p_max_pu: + - snapshot + - process + Process_p_min_pu: + - snapshot + - process + Process_p_nom: + - process + Process_p_nom_extendable: + - process + Process_p_nom_max: + - process + Process_p_nom_min: + - process + Process_p_nom_set: + - process + Process_p_set: + - snapshot + - process + Process_ramp_limit_down: + - process + Process_ramp_limit_up: + - process + Process_rate: + - process_output + Process_tech_capacity_weight: + - global_constraint + - process StorageUnit_capital_cost: - - storage_unit + - storage_unit StorageUnit_cyclic_state_of_charge: - - storage_unit + - storage_unit StorageUnit_efficiency_dispatch: - - storage_unit + - storage_unit StorageUnit_efficiency_store: - - storage_unit + - storage_unit StorageUnit_inflow: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_marginal_cost: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_marginal_cost_storage: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_max_hours: - - storage_unit + - storage_unit StorageUnit_operational_limit_weight: - - global_constraint - - storage_unit + - global_constraint + - storage_unit StorageUnit_p_max_pu: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_p_min_pu: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_p_nom: - - storage_unit + - storage_unit StorageUnit_p_nom_extendable: - - storage_unit + - storage_unit StorageUnit_p_nom_max: - - storage_unit + - storage_unit StorageUnit_p_nom_min: - - storage_unit + - storage_unit StorageUnit_p_nom_set: - - storage_unit + - storage_unit StorageUnit_p_set: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_primary_energy_weight: - - global_constraint - - storage_unit + - global_constraint + - storage_unit StorageUnit_retention: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_spill_cost: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_state_of_charge_initial: - - storage_unit + - storage_unit StorageUnit_state_of_charge_set: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_tech_capacity_weight: - - global_constraint - - storage_unit + - global_constraint + - storage_unit Store_capital_cost: - - store + - store Store_e_cyclic: - - store + - store Store_e_initial: - - store + - store Store_e_max_pu: - - snapshot - - store + - snapshot + - store Store_e_min_pu: - - snapshot - - store + - snapshot + - store Store_e_nom: - - store + - store Store_e_nom_extendable: - - store + - store Store_e_nom_max: - - store + - store Store_e_nom_min: - - store + - store Store_e_nom_set: - - store + - store Store_e_set: - - snapshot - - store + - snapshot + - store Store_marginal_cost: - - snapshot - - store + - snapshot + - store Store_marginal_cost_storage: - - snapshot - - store + - snapshot + - store Store_operational_limit_weight: - - global_constraint - - store + - global_constraint + - store Store_primary_energy_weight: - - global_constraint - - store + - global_constraint + - store Store_retention: - - snapshot - - store + - snapshot + - store Store_tech_capacity_weight: - - global_constraint - - store + - global_constraint + - store + Transformer_active: + - snapshot + - transformer + Transformer_capital_cost: + - transformer + Transformer_capital_weight: + - transformer + Transformer_cycle_weight: + - transformer + - cycle + Transformer_phase_shift_weight: + - transformer + - cycle + Transformer_s_max_pu: + - snapshot + - transformer + Transformer_s_nom: + - transformer + Transformer_s_nom_extendable: + - transformer + Transformer_s_nom_max: + - transformer + Transformer_s_nom_min: + - transformer + Transformer_s_nom_set: + - transformer + Transformer_s_set: + - snapshot + - transformer snapshot_is_last: - - snapshot + - snapshot snapshot_weightings_generators: - - snapshot + - snapshot snapshot_weightings_objective: - - snapshot + - snapshot snapshot_weightings_stores: - - snapshot + - snapshot relations: -- Generator_bus -- Line_bus0 -- Line_bus1 -- Link_bus0 -- Link_output_bus -- Link_output_link -- Load_bus -- StorageUnit_bus -- Store_bus + - Generator_bus + - Line_bus0 + - Line_bus1 + - Link_bus0 + - Link_output_bus + - Link_output_link + - Load_bus + - Process_output_bus + - Process_output_process + - StorageUnit_bus + - Store_bus + - Transformer_bus0 + - Transformer_bus1 variables: Generator_n_mod: - - generator + - generator Generator_p: - - snapshot - - generator + - snapshot + - generator Generator_p_nom_ext: - - generator + - generator Generator_shut_down: - - snapshot - - generator + - snapshot + - generator Generator_start_up: - - snapshot - - generator + - snapshot + - generator Generator_status: - - snapshot - - generator + - snapshot + - generator Line_s: - - snapshot - - line + - snapshot + - line Line_s_nom_ext: - - line + - line Link_p: - - snapshot - - link + - snapshot + - link Link_p_nom_ext: - - link + - link + Process_p: + - snapshot + - process + Process_p_nom_ext: + - process StorageUnit_p_dispatch: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_p_nom_ext: - - storage_unit + - storage_unit StorageUnit_p_store: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_spill: - - snapshot - - storage_unit + - snapshot + - storage_unit StorageUnit_state_of_charge: - - snapshot - - storage_unit + - snapshot + - storage_unit Store_e: - - snapshot - - store + - snapshot + - store Store_e_nom_ext: - - store + - store Store_p: - - snapshot - - store + - snapshot + - store + Transformer_s: + - snapshot + - transformer + Transformer_s_nom_ext: + - transformer diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 8f7ef633..17af5401 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -32,6 +32,8 @@ 'StorageUnit_p_nom_ext', 'Store_e_nom_ext', 'Line_s_nom_ext', + 'Process_p_nom_ext', + 'Transformer_s_nom_ext', } From 4c042cdf85e458380b9f7073540004ea104831f7 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 20:14:17 +0200 Subject: [PATCH 03/75] docs(pypsa): the spec covers quadratic marginal cost --- .prettierignore | 1 - docs/examples/pypsa.md | 61 ++++- docs/examples/pypsa_quadratic.md | 217 ------------------ examples/pypsa.yaml | 8 + examples/pypsa_quadratic.yaml | 149 ------------ .../pypsa/rung_10_quadratic_costs.py | 5 +- examples/symbols/pypsa.yaml | 2 + examples/symbols/pypsa_quadratic.yaml | 30 --- mkdocs.yml | 1 - tests/fixtures/pypsa_standard_shape.yaml | 6 + tools/gallery.py | 1 - 11 files changed, 76 insertions(+), 405 deletions(-) delete mode 100644 docs/examples/pypsa_quadratic.md delete mode 100644 examples/pypsa_quadratic.yaml delete mode 100644 examples/symbols/pypsa_quadratic.yaml diff --git a/.prettierignore b/.prettierignore index 1aa182c8..4cdba9bd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,7 +24,6 @@ docs/examples/dispatch.md docs/examples/commitment.md docs/examples/operators.md docs/examples/pypsa.md -docs/examples/pypsa_quadratic.md docs/examples/pypsa_linearized_uc.md docs/examples/pypsa_losses.md diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index b386864f..7acdd7ed 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -86,7 +86,7 @@ def build(): | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | | [`marginal_cost`](#objective) | done | | -| [`marginal_cost_quadratic`](pypsa_quadratic.md) | done | rung 10, a file of its own | +| [`marginal_cost_quadratic`](#objective) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | | `objective_constant` | split | an objective shift, compared net of `n._objective_constant` — rungs 11 and 13 carry a nonzero one, `21915277.52` and `160.0`, so the netting is under test | @@ -811,6 +811,59 @@ def build(): +### Rung 10 — quadratic costs + +A marginal cost quadratic in output: PyPSA's `marginal_cost_quadratic`, one +squared term per component in the objective, each snapshot weighted by the hours +it stands for. Generator and Link carry it here; PyPSA also carries it on +storage units and stores, one more term each of the same shape. A plain run +feeds zero, so the term vanishes and the objective stays linear. + +| PyPSA | status | note | +| --- | --- | --- | +| [`marginal_cost_quadratic`](#objective) | done | degree 2 in the objective; Generator and Link here | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `12587.437500000098`, 60 rows. + +
+The network, as PyPSA code + +`rung_10_quadratic_costs.py` + +```python +"""Rung 10: quadratic costs — a marginal cost quadratic in output.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'village') + n.add('Generator', 'steam', bus='north', p_nom=80, marginal_cost=5, marginal_cost_quadratic=0.08) + n.add('Generator', 'engine', bus='north', p_nom=80, marginal_cost=20, marginal_cost_quadratic=0.01) + n.add( + 'Link', + 'wire2', + bus0='north', + bus1='village', + p_nom=40, + p_min_pu=-1, + efficiency=0.9, + marginal_cost=1, + marginal_cost_quadratic=0.02, + ) + n.add('Load', 'village_load', bus='village', p_set=15) + n.add('Load', 'extra10', bus='north', p_set=[30, 50, 40, 60]) + return n +``` + +
+ + ### Rung 11 — ac-dc-meshed PyPSA's `ac_dc_meshed` example, whole: meshed AC and DC, extendable lines, @@ -1577,6 +1630,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | | $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | +| $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | | $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | @@ -1603,6 +1657,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once | | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the horizon's end — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at the first snapshots is lost | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | +| $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | | $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | @@ -4146,7 +4201,9 @@ scenario_opex: description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) @@ -4159,7 +4216,7 @@ scenario_opex: ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` #### Variable domains diff --git a/docs/examples/pypsa_quadratic.md b/docs/examples/pypsa_quadratic.md deleted file mode 100644 index 866e7e3f..00000000 --- a/docs/examples/pypsa_quadratic.md +++ /dev/null @@ -1,217 +0,0 @@ - - -# PyPSA, the quadratic class - -Rung 10 of [PyPSA in one file](pypsa.md): PyPSA's `marginal_cost_quadratic`, -stated on rung 1's transport surface in a file of its own. Its network is the -shared spine, shown once on [the rung ladder's page](pypsa.md#index). - -## Rung 10 — quadratic costs - -| PyPSA | status | note | -| --------------------------------------- | ------ | -------------------------------------------------------------------------- | -| [`marginal_cost_quadratic`](#objective) | done | degree 2 in the objective; Generator and Link here — PyPSA also carries it on storage units and stores, one more term each of the same shape | - - -> ✔ `pypsa 1.3.0` solves this rung's network at objective `12587.437500000098`, 60 rows. - -
-The network, as PyPSA code - -`rung_10_quadratic_costs.py` - -```python -"""Rung 10: quadratic costs — a marginal cost quadratic in output, stated by `pypsa_quadratic.yaml`.""" - -from __future__ import annotations - -import spine - -#: This rung binds a file of its own. -MODEL = 'pypsa_quadratic.yaml' - - -def build(): - """The spine plus this rung's additions, as a ``pypsa.Network``.""" - n = spine.build() - n.add('Bus', 'village') - n.add('Generator', 'steam', bus='north', p_nom=80, marginal_cost=5, marginal_cost_quadratic=0.08) - n.add('Generator', 'engine', bus='north', p_nom=80, marginal_cost=20, marginal_cost_quadratic=0.01) - n.add( - 'Link', - 'wire2', - bus0='north', - bus1='village', - p_nom=40, - p_min_pu=-1, - efficiency=0.9, - marginal_cost=1, - marginal_cost_quadratic=0.02, - ) - n.add('Load', 'village_load', bus='village', p_set=15) - n.add('Load', 'extra10', bus='north', p_set=[30, 50, 40, 60]) - return n -``` - -
- - -## The file - - -The quadratic class of a plain `n.optimize()`: PyPSA's `marginal_cost_quadratic`, stated on rung 1's transport surface in a file of its own. One model cannot carry a quadratic objective beside commitment's integer variables and still solve on HiGHS, because degree is the model's property and not the data's. So the class a free solver takes as a QP lives here, and `examples/pypsa.yaml` stays the mixed-integer one. PyPSA also carries the attribute on storage units and stores; each is one more term of the same shape. - -#### Sets - -| Symbol | Meaning | -|---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | -| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | -| $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | - -#### Parameters - -| Symbol | Meaning | -|---|---| -| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | -| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | -| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | -| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | -| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | -| $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | -| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | -| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | -| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | -| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | - -#### Variables - -| Symbol | Meaning | -|---|---| -| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | -| $`f`$ | `Link_p` over $`\mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | - -### Objective - -```yaml -objective: - sense: minimize - description: operating cost, linear and quadratic, each snapshot weighted by the hours it stands for - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective) -``` - -```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ l \in \mathcal{L}} f_{t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ l \in \mathcal{L}} f_{t,l} \cdot f_{t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} -``` - -### `Generator-fix-p-lower` - -`Generator_fix_p_lower` - -```yaml -Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom -``` - -```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -### `Generator-fix-p-upper` - -`Generator_fix_p_upper` - -```yaml -Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom -``` - -```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -### `Link-fix-p-lower` - -`Link_fix_p_lower` - -```yaml -Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom -``` - -```math -f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Link-fix-p-upper` - -`Link_fix_p_upper` - -```yaml -Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom -``` - -```math -f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Bus-nodal_balance` - -`Bus_nodal_balance` - -```yaml -Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) -``` - -```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} -``` - -#### Variable domains - -**`Generator_p`** - -```math -p_{t,g} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -**`Link_p`** - -```math -f_{t,l} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - - -Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 4e6f7a2c..591b4fc6 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -77,6 +77,9 @@ parameters: Generator_marginal_cost: description: cost of one unit of output dims: [snapshot, generator] + Generator_marginal_cost_quadratic: + description: cost of the square of one unit of output + dims: [snapshot, generator] Generator_committable: description: whether output is gated by an on/off status decision dims: [generator] @@ -182,6 +185,9 @@ parameters: Link_marginal_cost: description: cost of one unit of flow dims: [snapshot, link] + Link_marginal_cost_quadratic: + description: cost of the square of one unit of flow + dims: [snapshot, link] Process_p_nom: description: nominal internal power dims: [process] @@ -984,7 +990,9 @@ expressions: description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) diff --git a/examples/pypsa_quadratic.yaml b/examples/pypsa_quadratic.yaml deleted file mode 100644 index e8c665a9..00000000 --- a/examples/pypsa_quadratic.yaml +++ /dev/null @@ -1,149 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -description: >- - The quadratic class of a plain `n.optimize()`: PyPSA's - `marginal_cost_quadratic`, stated on rung 1's transport surface in a file of - its own. One model cannot carry a quadratic objective beside commitment's - integer variables and still solve on HiGHS, because degree is the model's - property and not the data's. So the class a free solver takes as a QP lives - here, and `examples/pypsa.yaml` stays the mixed-integer one. PyPSA also carries the - attribute on storage units and stores; each is one more term of the same - shape. - -dimensions: - snapshot: - description: dispatch periods - dtype: datetime - bus: - description: network nodes - generator: - description: generating units, each on one bus - link: - description: controllable connections, each from one bus to the buses it delivers to - link_output: - description: >- - a link's output ports, one label per port a link declares — PyPSA's - `bus1`, `bus2`, … columns read long, so a link of any number of output - ports is one term in the balance, data prep - load: - description: demands, each on one bus - -parameters: - snapshot_weightings_objective: - description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost - dims: [snapshot] - Generator_p_nom: - description: nominal power - dims: [generator] - Generator_p_min_pu: - description: least output, per unit of nominal power - dims: [snapshot, generator] - Generator_p_max_pu: - description: most output, per unit of nominal power — an availability profile - dims: [snapshot, generator] - Generator_marginal_cost: - description: cost of one unit of output - dims: [snapshot, generator] - Generator_marginal_cost_quadratic: - description: cost of the square of one unit of output - dims: [snapshot, generator] - Link_p_nom: - description: nominal power - dims: [link] - Link_p_min_pu: - description: least flow, per unit of nominal power — negative for a link that carries both ways - dims: [snapshot, link] - Link_p_max_pu: - description: most flow, per unit of nominal power - dims: [snapshot, link] - Link_efficiency: - description: >- - share of the flow that arrives at an output port, PyPSA's `efficiency`, - `efficiency2`, … read long — negative where that port consumes rather - than delivers - dims: [link_output] - Link_marginal_cost: - description: cost of one unit of flow - dims: [snapshot, link] - Link_marginal_cost_quadratic: - description: cost of the square of one unit of flow - dims: [snapshot, link] - Load_p_set: - description: demand - dims: [snapshot, load] - -relations: - Generator_bus: - description: the bus a generator sits on - key: generator - values: bus - Link_bus0: - description: the bus a link leaves - key: link - values: bus - Link_output_link: - description: the link an output port belongs to - key: link_output - values: link - Link_output_bus: - description: >- - the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. - A link of three output ports is three labels here rather than a third - relation, so the file states any number of them - key: link_output - values: bus - Load_bus: - description: the bus a load sits on - key: load - values: bus - -variables: - Generator_p: - description: "`Generator-p` — output of a generator in a snapshot" - dims: [snapshot, generator] - Link_p: - description: >- - `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a - positive value withdraws there and injects at every bus the link's - output ports deliver to - dims: [snapshot, link] - -constraints: - Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom - Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom - Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom - Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom - Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, less what the links - take away, plus what arrives over them after losses, meets the load - there - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) - -objective: - sense: minimize - description: operating cost, linear and quadratic, each snapshot weighted by the hours it stands for - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective) diff --git a/examples/references/pypsa/rung_10_quadratic_costs.py b/examples/references/pypsa/rung_10_quadratic_costs.py index c4b37d77..c8d851f6 100644 --- a/examples/references/pypsa/rung_10_quadratic_costs.py +++ b/examples/references/pypsa/rung_10_quadratic_costs.py @@ -1,12 +1,9 @@ -"""Rung 10: quadratic costs — a marginal cost quadratic in output, stated by `pypsa_quadratic.yaml`.""" +"""Rung 10: quadratic costs — a marginal cost quadratic in output.""" from __future__ import annotations import spine -#: This rung binds a file of its own. -MODEL = 'pypsa_quadratic.yaml' - def build(): """The spine plus this rung's additions, as a ``pypsa.Network``.""" diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 07f01892..269b1c21 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -30,6 +30,7 @@ names: Generator_p_min_pu: '\underline{\mathrm{p}}' Generator_p_max_pu: '\overline{\mathrm{p}}' Generator_marginal_cost: '\mathrm{c}' + Generator_marginal_cost_quadratic: '\mathrm{c}^{(2)}' Generator_committable: '\mathrm{com}' Generator_ramp_limit_up: '\mathrm{ru}' Generator_ramp_limit_down: '\mathrm{rd}' @@ -65,6 +66,7 @@ names: Link_output_delay: '\mathrm{d}^{f}' Link_output_cyclic_delay: '\mathrm{cyc}^{f}' Link_marginal_cost: '\mathrm{c}^{f}' + Link_marginal_cost_quadratic: '\mathrm{c}^{f,(2)}' Load_p_set: '\mathrm{load}' Generator_p_set: '\mathrm{p}^{\mathrm{set}}' Link_p_set: '\mathrm{f}^{\mathrm{set}}' diff --git a/examples/symbols/pypsa_quadratic.yaml b/examples/symbols/pypsa_quadratic.yaml deleted file mode 100644 index 119bcdc3..00000000 --- a/examples/symbols/pypsa_quadratic.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -# How `examples/pypsa_quadratic.yaml` prints — the rung 1 symbols, plus the -# quadratic coefficients. -notation: latex -dimensions: - snapshot: { index: t, set: '\mathcal{T}' } - bus: { index: n, set: '\mathcal{N}' } - generator: { index: g, set: '\mathcal{G}' } - link: { index: l, set: '\mathcal{L}' } - link_output: { index: o, set: '\mathcal{O}' } - load: { index: d, set: '\mathcal{D}' } -names: - snapshot_weightings_objective: '\mathrm{w}' - Generator_p: p - Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' - Generator_p_min_pu: '\underline{\mathrm{p}}' - Generator_p_max_pu: '\overline{\mathrm{p}}' - Generator_marginal_cost: '\mathrm{c}' - Generator_marginal_cost_quadratic: '\mathrm{c}^{(2)}' - Link_p: f - Link_p_nom: '\mathrm{f}^{\mathrm{nom}}' - Link_p_min_pu: '\underline{\mathrm{f}}' - Link_p_max_pu: '\overline{\mathrm{f}}' - Link_efficiency: '\eta' - Link_marginal_cost: '\mathrm{c}^{f}' - Link_marginal_cost_quadratic: '\mathrm{c}^{f,(2)}' - Load_p_set: '\mathrm{load}' diff --git a/mkdocs.yml b/mkdocs.yml index 33d2c00b..acdf2310 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,7 +63,6 @@ nav: - Unit commitment: examples/commitment.md - One construct per model: examples/operators.md - PyPSA in one file: examples/pypsa.md - - PyPSA, the quadratic class: examples/pypsa_quadratic.md - PyPSA, the relaxed commitment: examples/pypsa_linearized_uc.md - PyPSA, the lossy lines: examples/pypsa_losses.md # Everything a reader does not need in order to write a model: the design diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index cde00bcb..c93d05a4 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -347,6 +347,9 @@ parameters: Generator_marginal_cost: - snapshot - generator + Generator_marginal_cost_quadratic: + - snapshot + - generator Generator_min_down_time: - generator Generator_min_up_time: @@ -451,6 +454,9 @@ parameters: Link_marginal_cost: - snapshot - link + Link_marginal_cost_quadratic: + - snapshot + - link Link_output_cyclic_delay: - link_output Link_output_delay: diff --git a/tools/gallery.py b/tools/gallery.py index de078b7a..fc36a48c 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -44,7 +44,6 @@ #: the declaration's own description. DECLARED = { 'pypsa.md': ROOT / 'examples' / 'pypsa.yaml', - 'pypsa_quadratic.md': ROOT / 'examples' / 'pypsa_quadratic.yaml', 'pypsa_linearized_uc.md': ROOT / 'examples' / 'pypsa_linearized_uc.yaml', 'pypsa_losses.md': ROOT / 'examples' / 'pypsa_losses.yaml', } From dc0ec065d739027d96624983d037f878dde69f95 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 20:36:56 +0200 Subject: [PATCH 04/75] docs(pypsa): the spec covers transmission losses --- .prettierignore | 1 - docs/examples/pypsa.md | 260 +++++++++--- docs/examples/pypsa_losses.md | 426 -------------------- examples/pypsa.yaml | 68 +++- examples/pypsa_losses.yaml | 260 ------------ examples/references/pypsa/rung_13_losses.py | 3 +- examples/symbols/pypsa.yaml | 5 + examples/symbols/pypsa_losses.yaml | 43 -- mkdocs.yml | 3 +- tests/fixtures/pypsa_standard_shape.yaml | 12 + tools/gallery.py | 1 - 11 files changed, 273 insertions(+), 809 deletions(-) delete mode 100644 docs/examples/pypsa_losses.md delete mode 100644 examples/pypsa_losses.yaml delete mode 100644 examples/symbols/pypsa_losses.yaml diff --git a/.prettierignore b/.prettierignore index 4cdba9bd..6456f465 100644 --- a/.prettierignore +++ b/.prettierignore @@ -25,7 +25,6 @@ docs/examples/commitment.md docs/examples/operators.md docs/examples/pypsa.md docs/examples/pypsa_linearized_uc.md -docs/examples/pypsa_losses.md # The PyPSA reference scripts write this file; prettier would reformat what # they stamp, and the two would fight over it exactly as above. diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 7acdd7ed..94b1f84b 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1153,6 +1153,76 @@ def build(): +### Rung 13 — transmission losses + +`n.optimize(transmission_losses={'mode': 'tangents', 'segments': K})`: a line +dissipates a loss its flow buys along a fan of `K` tangents to the quadratic +loss curve, half charged at either end of the line. The loss variable, its cap +and the tangent rows exist only where `transmission_losses` is on; the tangent +slopes and offsets are data prep, one per `segment`. A plain run leaves the flag +off and supplies no segments, so the loss is absent and reads as zero in the +balance, and the model collapses to the lossless one. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Line-loss`](#variable-domains) | done | absent, and zero in the balance, where lossless | +| [`Line-fix-s-*`, `Line-ext-s-*`](#line-fix-s-lower) | done | the loss counted against the rating | +| [`Bus-nodal_balance`](#bus-nodal_balance) | done | half of each incident line's loss at either end | +| [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep | +| [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | +| [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1) | split | | +| `Line-loss_secants-*` | out | the secant mode solves for its segment count | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10645.295879552297`, 150 rows. + +
+The network, as PyPSA code + +`rung_13_losses.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 13: transmission losses in tangent form — a loss per line.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'tangents', 'segments': 2}} + + +def build(): + """The spine plus a 110 kV triangle of lines, one of them extendable — ohms a real line has, so the loss stays a few percent of the flow.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro13', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel13', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab13', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Line', 'bc13', bus0='b', bus1='c', carrier='AC', x=60, r=9.7, s_nom=60) + n.add( + 'Line', + 'ca13', + bus0='c', + bus1='a', + carrier='AC', + x=45, + r=6, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + ) + n.add('Load', 'town13', bus='c', p_set=[35, 55, 15, 45]) + return n +``` + +
+ + ### Rung 14 — two-stage stochastic Two futures and a risk preference: `n.set_scenarios(...)` with @@ -1568,12 +1638,6 @@ def build(): -### Not on a rung - -| PyPSA | status | note | -| ------------------------------ | ------ | ------------------------------------ | -| [`{c}-loss*`](pypsa_losses.md) | done | rung 13, a file of its own; tangent form | - ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -1616,7 +1680,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | | $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -| $`\mathcal{B}`$ | index $`b`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{B}`$ | index $`b`$ — `segment` — the tangents a line's loss curve is approximated by — PyPSA's `transmission_losses` count; none in a lossless run | +| $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | | $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | @@ -1752,6 +1817,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | | $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | | $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` count read as a flag, its tangents the `segment` axis; false with no segments is a lossless run | +| $`\overline{\ell}`$ | `Line_loss_max` over $`\mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | +| $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep | +| $`\mathrm{b}`$ | `Line_loss_offset` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep | | $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\mathcal{M}`$ — nominal apparent power | | $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | | $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | @@ -1762,26 +1831,26 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | | $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row | | $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's fixed phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; a transformer with no shift or in no cycle has no row. PyPSA also admits an optimisable phase shift, a later rung this file does not carry | -| $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{B}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | -| $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{B}`$ — which way the row binds — `<=`, `>=` or `==` | -| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{B}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | +| $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | +| $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{I}`$ — which way the row binds — `<=`, `>=` or `==` | +| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | | $`\mathrm{last}`$ | `snapshot_is_last` over $`\mathcal{T}`$ — one at the horizon's last snapshot, zero elsewhere — data prep, how an expression reads a final level | -| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\mathcal{B} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | -| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\mathcal{B} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | -| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\mathcal{B} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | -| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\mathcal{B} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\mathcal{B} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\mathcal{B} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | -| $`\mathrm{len}`$ | `Line_volume_weight` over $`\mathcal{B} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set — data prep; a line outside it has no row | -| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\mathcal{B} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set — data prep; a link outside it has no row | -| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\mathcal{B} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set — data prep; a line outside it has no row | -| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\mathcal{B} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set — data prep; a link outside it has no row | -| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{B} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | +| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | +| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set — data prep; a line outside it has no row | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set — data prep; a link outside it has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set — data prep; a line outside it has no row | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set — data prep; a link outside it has no row | +| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row | #### Variables @@ -1801,6 +1870,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | +| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the tangents; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | @@ -1828,11 +1898,11 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{B}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{B}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | -| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{B}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | -| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{B}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | -| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{B}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{I}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | $`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. @@ -2779,14 +2849,14 @@ Generator_shut_down_p_nom_variable_upper: ```yaml Line_fix_s_lower: - description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating" + description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, line] where: not Line_s_nom_extendable AND Line_active - expression: Line_s >= -Line_s_max_pu * Line_s_nom + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom ``` ```math -s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-fix-s-upper` @@ -2795,14 +2865,14 @@ s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} ```yaml Line_fix_s_upper: - description: "`Line-fix-s-upper` — a fixed line carries at most its rating" + description: "`Line-fix-s-upper` — a fixed line carries at most its rating, the loss included" dims: [scenario, snapshot, line] where: not Line_s_nom_extendable AND Line_active - expression: Line_s <= Line_s_max_pu * Line_s_nom + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom ``` ```math -s_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-lower` @@ -2811,14 +2881,14 @@ s_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} ```yaml Line_ext_s_lower: - description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" + description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build, the loss counted against it" dims: [scenario, snapshot, line] where: Line_s_nom_extendable AND Line_active - expression: Line_s >= -Line_s_max_pu * Line_s_nom_ext + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext ``` ```math -s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-upper` @@ -2827,14 +2897,14 @@ s_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \i ```yaml Line_ext_s_upper: - description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" + description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build, the loss included" dims: [scenario, snapshot, line] where: Line_s_nom_extendable AND Line_active - expression: Line_s <= Line_s_max_pu * Line_s_nom_ext + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext ``` ```math -s_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s_nom-lower` @@ -2901,6 +2971,57 @@ Line_s_set: s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} ``` +### `Line-loss_upper` + +`Line_loss_upper` + +```yaml +Line_loss_upper: + description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + expression: Line_loss <= Line_loss_max +``` + +```math +\ell_{\xi,t,k} \le \overline{\ell}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +### `Line-loss_tangents-{k}-1` + +`Line_loss_tangents_forward` + +```yaml +Line_loss_tangents_forward: + description: >- + `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its + curve for flow one way; PyPSA names one row per segment `k`, this block + states them all over the segment dimension + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset +``` + +```math +\ell_{\xi,t,k} + \mathrm{a}_{t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +### `Line-loss_tangents-{k}--1` + +`Line_loss_tangents_reverse` + +```yaml +Line_loss_tangents_reverse: + description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset +``` + +```math +\ell_{\xi,t,k} - \mathrm{a}_{t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + ### `Transformer-fix-s-lower` `Transformer_fix_s_lower` @@ -3580,7 +3701,7 @@ GlobalConstraint_primary_energy_ub: ``` ```math -\mathit{primary\_energy}_{\xi,b} \le \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{primary\_energy}_{\xi,i} \le \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} ``` ### `primary_energy` @@ -3596,7 +3717,7 @@ GlobalConstraint_primary_energy_lb: ``` ```math -\mathit{primary\_energy}_{\xi,b} \ge \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{primary\_energy}_{\xi,i} \ge \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} ``` ### `primary_energy` @@ -3612,7 +3733,7 @@ GlobalConstraint_primary_energy_eq: ``` ```math -\mathit{primary\_energy}_{\xi,b} = \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{primary\_energy}_{\xi,i} = \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} ``` ### `operational_limit` @@ -3628,7 +3749,7 @@ GlobalConstraint_operational_limit_ub: ``` ```math -\mathit{operational\_limit}_{\xi,b} \le \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{operational\_limit}_{\xi,i} \le \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} ``` ### `operational_limit` @@ -3644,7 +3765,7 @@ GlobalConstraint_operational_limit_lb: ``` ```math -\mathit{operational\_limit}_{\xi,b} \ge \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{operational\_limit}_{\xi,i} \ge \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} ``` ### `operational_limit` @@ -3660,7 +3781,7 @@ GlobalConstraint_operational_limit_eq: ``` ```math -\mathit{operational\_limit}_{\xi,b} = \mathrm{K}_{b} \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{operational\_limit}_{\xi,i} = \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -3676,7 +3797,7 @@ GlobalConstraint_transmission_volume_expansion_limit_ub: ``` ```math -\mathit{transmission\_volume\_expansion}_{b} \le \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_volume\_expansion}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -3692,7 +3813,7 @@ GlobalConstraint_transmission_volume_expansion_limit_lb: ``` ```math -\mathit{transmission\_volume\_expansion}_{b} \ge \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_volume\_expansion}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -3708,7 +3829,7 @@ GlobalConstraint_transmission_volume_expansion_limit_eq: ``` ```math -\mathit{transmission\_volume\_expansion}_{b} = \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_volume\_expansion}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -3724,7 +3845,7 @@ GlobalConstraint_transmission_expansion_cost_limit_ub: ``` ```math -\mathit{transmission\_expansion\_cost}_{b} \le \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_expansion\_cost}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -3740,7 +3861,7 @@ GlobalConstraint_transmission_expansion_cost_limit_lb: ``` ```math -\mathit{transmission\_expansion\_cost}_{b} \ge \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_expansion\_cost}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -3756,7 +3877,7 @@ GlobalConstraint_transmission_expansion_cost_limit_eq: ``` ```math -\mathit{transmission\_expansion\_cost}_{b} = \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_expansion\_cost}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -3772,7 +3893,7 @@ GlobalConstraint_tech_capacity_expansion_limit_ub: ``` ```math -\mathit{tech\_capacity\_expansion}_{b} \le \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{<=}\text{'} +\mathit{tech\_capacity\_expansion}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -3788,7 +3909,7 @@ GlobalConstraint_tech_capacity_expansion_limit_lb: ``` ```math -\mathit{tech\_capacity\_expansion}_{b} \ge \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{>=}\text{'} +\mathit{tech\_capacity\_expansion}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -3804,7 +3925,7 @@ GlobalConstraint_tech_capacity_expansion_limit_eq: ``` ```math -\mathit{tech\_capacity\_expansion}_{b} = \mathrm{K}_{b} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{type}_{b} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{b} = \text{'}\mathrm{==}\text{'} +\mathit{tech\_capacity\_expansion}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} ``` ### `Bus-nodal_balance` @@ -3818,7 +3939,8 @@ Bus_nodal_balance: stores included, less what the links take away, plus what arrives over them after losses and any delay at every port they deliver to, each process port drawing or delivering at its own rate and each passive branch - carrying its flow, meets the load there. + carrying its flow, meets the load there, less half of every incident + line's loss — PyPSA dissipates a branch's loss half at either end. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -3831,13 +3953,15 @@ Bus_nodal_balance: + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - sum(Line_s, by=Line_bus0, over=line, into=bus) + sum(Line_s, by=Line_bus1, over=line, into=bus) + - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) + - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` @@ -4127,7 +4251,7 @@ primary_energy: ``` ```math -\mathit{primary\_energy}_{\xi,b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{a}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{e}_{b,v} \right) \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} +\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{a}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `operational_limit` @@ -4145,7 +4269,7 @@ operational_limit: ``` ```math -\mathit{operational\_limit}_{\xi,b} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{b}_{b,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{h}_{b,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{e}_{b,v} \right) \qquad \forall\, \xi \in \Xi,\ b \in \mathcal{B} +\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{b}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_volume_expansion` @@ -4159,7 +4283,7 @@ transmission_volume_expansion: ``` ```math -\mathit{transmission\_volume\_expansion}_{b} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{b,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{b,l} \qquad \forall\, b \in \mathcal{B} +\mathit{transmission\_volume\_expansion}_{i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{i,l} \qquad \forall\, i \in \mathcal{I} ``` ### `transmission_expansion_cost` @@ -4173,7 +4297,7 @@ transmission_expansion_cost: ``` ```math -\mathit{transmission\_expansion\_cost}_{b} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{b,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{b,l} \qquad \forall\, b \in \mathcal{B} +\mathit{transmission\_expansion\_cost}_{i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{i,l} \qquad \forall\, i \in \mathcal{I} ``` ### `tech_capacity_expansion` @@ -4191,7 +4315,7 @@ tech_capacity_expansion: ``` ```math -\mathit{tech\_capacity\_expansion}_{b} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{b,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{b,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{b,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{b,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{b,v} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{b,j} \qquad \forall\, b \in \mathcal{B} +\mathit{tech\_capacity\_expansion}_{i} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{i,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{i,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{i,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{i,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{i,v} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{i,j} \qquad \forall\, i \in \mathcal{I} ``` ### `scenario_opex` @@ -4305,6 +4429,12 @@ u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \ s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{on}^{s}_{t,k} ``` +**`Line_loss`** + +```math +\ell_{\xi,t,k} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + **`Transformer_s`** ```math diff --git a/docs/examples/pypsa_losses.md b/docs/examples/pypsa_losses.md deleted file mode 100644 index 441da45b..00000000 --- a/docs/examples/pypsa_losses.md +++ /dev/null @@ -1,426 +0,0 @@ - - -# PyPSA, the lossy lines - -Rung 13 of [PyPSA in one file](pypsa.md): `n.optimize(transmission_losses={'mode': 'tangents', 'segments': K})`, stated on rung 6's lines in a -file of its own. Its network is the spine plus the script's own additions. - -## Rung 13 — transmission losses - -| PyPSA | status | note | -| --- | --- | --- | -| [`Line-loss`](#variable-domains) | done | | -| [`Line-fix-s-*`, `Line-ext-s-*`](#line-fix-s-lower) | done | the loss counted against the rating | -| [`Bus-nodal_balance`](#bus-nodal_balance) | done | half of each incident line's loss at either end | -| [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep | -| [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | -| [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1) | split | | -| `Line-loss_secants-*` | out | the secant mode solves for its segment count | - - -> ✔ `pypsa 1.3.0` solves this rung's network at objective `10645.295879552297`, 150 rows. - -
-The network, as PyPSA code - -`rung_13_losses.py` - -```python -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -"""Rung 13: transmission losses in tangent form — a loss per line, stated by `pypsa_losses.yaml`.""" - -from __future__ import annotations - -import spine - -MODEL = 'pypsa_losses.yaml' -OPTIMIZE = {'transmission_losses': {'mode': 'tangents', 'segments': 2}} - - -def build(): - """The spine plus a 110 kV triangle of lines, one of them extendable — ohms a real line has, so the loss stays a few percent of the flow.""" - n = spine.build() - n.add('Bus', ['a', 'b', 'c'], v_nom=110) - n.add('Generator', 'hydro13', bus='a', p_nom=80, marginal_cost=10) - n.add('Generator', 'diesel13', bus='b', p_nom=80, marginal_cost=50) - n.add('Line', 'ab13', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) - n.add('Line', 'bc13', bus0='b', bus1='c', carrier='AC', x=60, r=9.7, s_nom=60) - n.add( - 'Line', - 'ca13', - bus0='c', - bus1='a', - carrier='AC', - x=45, - r=6, - s_nom=40, - s_nom_extendable=True, - s_nom_max=90, - capital_cost=4, - ) - n.add('Load', 'town13', bus='c', p_set=[35, 55, 15, 45]) - return n -``` - -
- - -## The file - - -The lossy class of a plain `n.optimize()`: `transmission_losses` in its tangent form, stated on rung 6's lines in a file of its own. A line dissipates a loss its flow buys along a fan of tangents to the quadratic curve, half at either end — a variable and rows the keyword adds, which no `where:` can add to `examples/pypsa.yaml`. The fan's slopes and offsets are data prep, one per segment. - -#### Sets - -| Symbol | Meaning | -|---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | -| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | -| $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -| $`\mathcal{K}`$ | index $`k`$ — `segment` — the tangents the loss curve is approximated by, PyPSA's `segments` | -| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | - -#### Parameters - -| Symbol | Meaning | -|---|---| -| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | -| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | -| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | -| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | -| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | -| $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\mathcal{K}`$ — nominal apparent power | -| $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | -| $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | -| $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\mathcal{K}`$ — least nominal apparent power an extendable line may be built at | -| $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\mathcal{K}`$ — most nominal apparent power an extendable line may be built at | -| $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\mathcal{K}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | -| $`\overline{\ell}`$ | `Line_loss_max` over $`\mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | -| $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{K}`$ — the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep | -| $`\mathrm{b}`$ | `Line_loss_offset` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{K}`$ — where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep | -| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | -| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | -| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | - -#### Variables - -| Symbol | Meaning | -|---|---| -| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | -| $`s`$ | `Line_s` over $`\mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1` | -| $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | -| $`f`$ | `Link_p` over $`\mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | -| $`\ell`$ | `Line_loss` over $`\mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the tangents | - -### Objective - -```yaml -objective: - sense: minimize - description: operating cost by weighted snapshot, plus what the lines cost to build - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(Line_s_nom_ext * Line_capital_cost) -``` - -```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} + \sum_{t \in \mathcal{T},\ l \in \mathcal{L}} f_{t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} -``` - -### `Generator-fix-p-lower` - -`Generator_fix_p_lower` - -```yaml -Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom -``` - -```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -### `Generator-fix-p-upper` - -`Generator_fix_p_upper` - -```yaml -Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom -``` - -```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -### `Link-fix-p-lower` - -`Link_fix_p_lower` - -```yaml -Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom -``` - -```math -f_{t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Link-fix-p-upper` - -`Link_fix_p_upper` - -```yaml -Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom -``` - -```math -f_{t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -### `Line-fix-s-lower` - -`Line_fix_s_lower` - -```yaml -Line_fix_s_lower: - description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" - dims: [snapshot, line] - where: not Line_s_nom_extendable - expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom -``` - -```math -s_{t,k} - \ell_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} -``` - -### `Line-fix-s-upper` - -`Line_fix_s_upper` - -```yaml -Line_fix_s_upper: - description: "`Line-fix-s-upper` — a fixed line carries at most its rating, loss included" - dims: [snapshot, line] - where: not Line_s_nom_extendable - expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom -``` - -```math -s_{t,k} + \ell_{t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} -``` - -### `Line-ext-s-lower` - -`Line_ext_s_lower` - -```yaml -Line_ext_s_lower: - description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable - expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext -``` - -```math -s_{t,k} - \ell_{t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} -``` - -### `Line-ext-s-upper` - -`Line_ext_s_upper` - -```yaml -Line_ext_s_upper: - description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable - expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext -``` - -```math -s_{t,k} + \ell_{t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} -``` - -### `Line-ext-s_nom-lower` - -`Line_ext_s_nom_lower` - -```yaml -Line_ext_s_nom_lower: - description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [line] - where: Line_s_nom_extendable - expression: Line_s_nom_ext >= Line_s_nom_min -``` - -```math -S_{k} \ge \underline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} -``` - -### `Line-ext-s_nom-upper` - -`Line_ext_s_nom_upper` - -```yaml -Line_ext_s_nom_upper: - description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [line] - where: Line_s_nom_extendable AND Line_s_nom_max - expression: Line_s_nom_ext <= Line_s_nom_max -``` - -```math -S_{k} \le \overline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \overline{\mathrm{s}}^{\mathrm{nom}}_{k} \text{ is defined} -``` - -### `Kirchhoff-Voltage-Law` - -`Kirchhoff_Voltage_Law` - -```yaml -Kirchhoff_Voltage_Law: - description: >- - `Kirchhoff-Voltage-Law` — around every independent cycle the - impedance-weighted flows sum to nothing, which is what makes the linear - power flow physical rather than transport - dims: [snapshot, cycle] - expression: sum(Line_s * Line_cycle_weight, over=line) == 0 -``` - -```math -\sum_{k \in \mathcal{K}} s_{t,k} \cdot \mathrm{x}_{k,c} = 0 \qquad \forall\, t \in \mathcal{T},\ c \in \mathcal{C} -``` - -### `Bus-nodal_balance` - -`Bus_nodal_balance` - -```yaml -Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, plus what the links and - lines bring, meets the load there, less half of every incident line's - loss — PyPSA dissipates a branch's loss half at either end - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - - sum(Line_s, by=Line_bus0, over=line, into=bus) - + sum(Line_s, by=Line_bus1, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) -``` - -```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{t,k} \right) = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} -``` - -### `Line-loss_upper` - -`Line_loss_upper` - -```yaml -Line_loss_upper: - description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" - dims: [snapshot, line] - expression: Line_loss <= Line_loss_max -``` - -```math -\ell_{t,k} \le \overline{\ell}_{t,k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} -``` - -### `Line-loss_tangents-{k}-1` - -`Line_loss_tangents_forward` - -```yaml -Line_loss_tangents_forward: - description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its - curve for flow one way; PyPSA names one row per segment `k`, this block - states them all over the segment dimension - dims: [snapshot, line, segment] - expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset -``` - -```math -\ell_{t,k} + \mathrm{a}_{t,k,k} \cdot s_{t,k} \ge \mathrm{b}_{t,k,k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K},\ k \in \mathcal{K} -``` - -### `Line-loss_tangents-{k}--1` - -`Line_loss_tangents_reverse` - -```yaml -Line_loss_tangents_reverse: - description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" - dims: [snapshot, line, segment] - expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset -``` - -```math -\ell_{t,k} - \mathrm{a}_{t,k,k} \cdot s_{t,k} \ge \mathrm{b}_{t,k,k} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K},\ k \in \mathcal{K} -``` - -#### Variable domains - -**`Generator_p`** - -```math -p_{t,g} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -**`Line_s`** - -```math -s_{t,k} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} -``` - -**`Line_s_nom_ext`** - -```math -S_{k} \in \mathbb{R} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} -``` - -**`Link_p`** - -```math -f_{t,l} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} -``` - -**`Line_loss`** - -```math -\ell_{t,k} \ge 0 \qquad \forall\, t \in \mathcal{T},\ k \in \mathcal{K} -``` - - -Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 591b4fc6..e85e1e05 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -49,6 +49,9 @@ dimensions: description: passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift cycle: description: independent cycles of the passive network graph — the cycle basis, data prep + segment: + description: the tangents a line's loss curve is approximated by — PyPSA's `transmission_losses` count; none in a lossless run + dtype: int global_constraint: description: PyPSA's `GlobalConstraint` rows, one label per declared limit period: @@ -498,6 +501,22 @@ parameters: the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row dims: [line, cycle] + transmission_losses: + description: >- + whether the network dissipates transmission losses — PyPSA's + `transmission_losses` count read as a flag, its tangents the `segment` + axis; false with no segments is a lossless run + dims: [] + dtype: bool + Line_loss_max: + description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep + dims: [snapshot, line] + Line_loss_slope: + description: the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep + dims: [snapshot, line, segment] + Line_loss_offset: + description: where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep + dims: [snapshot, line, segment] Transformer_s_nom: description: nominal apparent power dims: [transformer] @@ -767,6 +786,16 @@ variables: positive value withdraws there and injects at `Line_bus1`, lossless dims: [scenario, snapshot, line] where: Line_active + Line_loss: + description: >- + `Line-loss` — what a line dissipates carrying its flow, pushed down by the + cost and held up by the tangents; absent, and zero in the balance, where + the network is lossless + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + absence: zero + bounds: + lower: 0 Transformer_s: description: >- `Transformer-s` — PyPSA's `p0`, the flow measured at the @@ -1332,25 +1361,25 @@ constraints: where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_shut_down <= Generator_n_mod Line_fix_s_lower: - description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating" + description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, line] where: not Line_s_nom_extendable AND Line_active - expression: Line_s >= -Line_s_max_pu * Line_s_nom + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom Line_fix_s_upper: - description: "`Line-fix-s-upper` — a fixed line carries at most its rating" + description: "`Line-fix-s-upper` — a fixed line carries at most its rating, the loss included" dims: [scenario, snapshot, line] where: not Line_s_nom_extendable AND Line_active - expression: Line_s <= Line_s_max_pu * Line_s_nom + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom Line_ext_s_lower: - description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" + description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build, the loss counted against it" dims: [scenario, snapshot, line] where: Line_s_nom_extendable AND Line_active - expression: Line_s >= -Line_s_max_pu * Line_s_nom_ext + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext Line_ext_s_upper: - description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" + description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build, the loss included" dims: [scenario, snapshot, line] where: Line_s_nom_extendable AND Line_active - expression: Line_s <= Line_s_max_pu * Line_s_nom_ext + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext Line_ext_s_nom_lower: description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" dims: [line] @@ -1371,6 +1400,24 @@ constraints: dims: [scenario, snapshot, line] where: Line_s_set AND Line_active expression: Line_s == Line_s_set + Line_loss_upper: + description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + expression: Line_loss <= Line_loss_max + Line_loss_tangents_forward: + description: >- + `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its + curve for flow one way; PyPSA names one row per segment `k`, this block + states them all over the segment dimension + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset + Line_loss_tangents_reverse: + description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset Transformer_fix_s_lower: description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating" dims: [scenario, snapshot, transformer] @@ -1689,7 +1736,8 @@ constraints: stores included, less what the links take away, plus what arrives over them after losses and any delay at every port they deliver to, each process port drawing or delivering at its own rate and each passive branch - carrying its flow, meets the load there. + carrying its flow, meets the load there, less half of every incident + line's loss — PyPSA dissipates a branch's loss half at either end. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -1702,6 +1750,8 @@ constraints: + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - sum(Line_s, by=Line_bus0, over=line, into=bus) + sum(Line_s, by=Line_bus1, over=line, into=bus) + - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) + - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) diff --git a/examples/pypsa_losses.yaml b/examples/pypsa_losses.yaml deleted file mode 100644 index 5ffb4d9c..00000000 --- a/examples/pypsa_losses.yaml +++ /dev/null @@ -1,260 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -description: >- - The lossy class of a plain `n.optimize()`: `transmission_losses` in its - tangent form, stated on rung 6's lines in a file of its own. A line - dissipates a loss its flow buys along a fan of tangents to the quadratic - curve, half at either end — a variable and rows the keyword adds, which no - `where:` can add to `examples/pypsa.yaml`. The fan's slopes and offsets are - data prep, one per segment. - -dimensions: - snapshot: - description: dispatch periods - dtype: datetime - bus: - description: network nodes - generator: - description: generating units, each on one bus - link: - description: controllable connections, each from one bus to the buses it delivers to - link_output: - description: >- - a link's output ports, one label per port a link declares — PyPSA's - `bus1`, `bus2`, … columns read long, so a link of any number of output - ports is one term in the balance, data prep - line: - description: passive branches, each between two buses, their flow set by impedance - cycle: - description: independent cycles of the passive network graph — the cycle basis, data prep - segment: - description: the tangents the loss curve is approximated by, PyPSA's `segments` - dtype: int - load: - description: demands, each on one bus - -parameters: - snapshot_weightings_objective: - description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost - dims: [snapshot] - Generator_p_nom: - description: nominal power - dims: [generator] - Generator_p_min_pu: - description: least output, per unit of nominal power - dims: [snapshot, generator] - Generator_p_max_pu: - description: most output, per unit of nominal power — an availability profile - dims: [snapshot, generator] - Generator_marginal_cost: - description: cost of one unit of output - dims: [snapshot, generator] - Line_s_nom: - description: nominal apparent power - dims: [line] - Line_s_nom_extendable: - description: whether the nominal apparent power is a decision - dims: [line] - dtype: bool - Line_s_max_pu: - description: most flow either way, per unit of nominal apparent power - dims: [snapshot, line] - Line_s_nom_min: - description: least nominal apparent power an extendable line may be built at - dims: [line] - Line_s_nom_max: - description: most nominal apparent power an extendable line may be built at - dims: [line] - Line_capital_cost: - description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [line] - Line_cycle_weight: - description: >- - the line's series impedance, signed by its orientation in the cycle — - the cycle basis, data prep; a line in no cycle has no row - dims: [line, cycle] - Line_loss_max: - description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep - dims: [snapshot, line] - Line_loss_slope: - description: the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep - dims: [snapshot, line, segment] - Line_loss_offset: - description: where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep - dims: [snapshot, line, segment] - Link_p_nom: - description: nominal power - dims: [link] - Link_p_min_pu: - description: least flow, per unit of nominal power — negative for a link that carries both ways - dims: [snapshot, link] - Link_p_max_pu: - description: most flow, per unit of nominal power - dims: [snapshot, link] - Link_efficiency: - description: >- - share of the flow that arrives at an output port, PyPSA's `efficiency`, - `efficiency2`, … read long — negative where that port consumes rather - than delivers - dims: [link_output] - Link_marginal_cost: - description: cost of one unit of flow - dims: [snapshot, link] - Load_p_set: - description: demand - dims: [snapshot, load] - -relations: - Generator_bus: - description: the bus a generator sits on - key: generator - values: bus - Line_bus0: - description: the bus a line's flow is measured at - key: line - values: bus - Line_bus1: - description: the bus at a line's other end - key: line - values: bus - Link_bus0: - description: the bus a link leaves - key: link - values: bus - Link_output_link: - description: the link an output port belongs to - key: link_output - values: link - Link_output_bus: - description: >- - the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. - A link of three output ports is three labels here rather than a third - relation, so the file states any number of them - key: link_output - values: bus - Load_bus: - description: the bus a load sits on - key: load - values: bus - -variables: - Generator_p: - description: "`Generator-p` — output of a generator in a snapshot" - dims: [snapshot, generator] - Line_s: - description: >- - `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a - positive value withdraws there and injects at `Line_bus1` - dims: [snapshot, line] - Line_s_nom_ext: - description: >- - `Line-s_nom` — nominal apparent power where it is a decision; the - parameter of the same PyPSA name carries the fixed regime - dims: [line] - where: Line_s_nom_extendable - Link_p: - description: >- - `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a - positive value withdraws there and injects at every bus the link's - output ports deliver to - dims: [snapshot, link] - Line_loss: - description: "`Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the tangents" - dims: [snapshot, line] - bounds: - lower: 0 - -constraints: - Generator_fix_p_lower: - description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" - dims: [snapshot, generator] - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom - Generator_fix_p_upper: - description: "`Generator-fix-p-upper` — a generator outputs at most what is available" - dims: [snapshot, generator] - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom - Link_fix_p_lower: - description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" - dims: [snapshot, link] - expression: Link_p >= Link_p_min_pu * Link_p_nom - Link_fix_p_upper: - description: "`Link-fix-p-upper` — a link carries at most its nominal power" - dims: [snapshot, link] - expression: Link_p <= Link_p_max_pu * Link_p_nom - Line_fix_s_lower: - description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" - dims: [snapshot, line] - where: not Line_s_nom_extendable - expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom - Line_fix_s_upper: - description: "`Line-fix-s-upper` — a fixed line carries at most its rating, loss included" - dims: [snapshot, line] - where: not Line_s_nom_extendable - expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom - Line_ext_s_lower: - description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable - expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext - Line_ext_s_upper: - description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build" - dims: [snapshot, line] - where: Line_s_nom_extendable - expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext - Line_ext_s_nom_lower: - description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [line] - where: Line_s_nom_extendable - expression: Line_s_nom_ext >= Line_s_nom_min - Line_ext_s_nom_upper: - description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [line] - where: Line_s_nom_extendable AND Line_s_nom_max - expression: Line_s_nom_ext <= Line_s_nom_max - Kirchhoff_Voltage_Law: - description: >- - `Kirchhoff-Voltage-Law` — around every independent cycle the - impedance-weighted flows sum to nothing, which is what makes the linear - power flow physical rather than transport - dims: [snapshot, cycle] - expression: sum(Line_s * Line_cycle_weight, over=line) == 0 - Bus_nodal_balance: - description: >- - `Bus-nodal_balance` — what is generated at a bus, plus what the links and - lines bring, meets the load there, less half of every incident line's - loss — PyPSA dissipates a branch's loss half at either end - dims: [snapshot, bus] - expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - - sum(Line_s, by=Line_bus0, over=line, into=bus) - + sum(Line_s, by=Line_bus1, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) - Line_loss_upper: - description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" - dims: [snapshot, line] - expression: Line_loss <= Line_loss_max - Line_loss_tangents_forward: - description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its - curve for flow one way; PyPSA names one row per segment `k`, this block - states them all over the segment dimension - dims: [snapshot, line, segment] - expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset - Line_loss_tangents_reverse: - description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" - dims: [snapshot, line, segment] - expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset - -objective: - sense: minimize - description: operating cost by weighted snapshot, plus what the lines cost to build - expression: >- - sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective) - + sum(Link_p * Link_marginal_cost * snapshot_weightings_objective) - + sum(Line_s_nom_ext * Line_capital_cost) diff --git a/examples/references/pypsa/rung_13_losses.py b/examples/references/pypsa/rung_13_losses.py index 182ac34b..424af196 100644 --- a/examples/references/pypsa/rung_13_losses.py +++ b/examples/references/pypsa/rung_13_losses.py @@ -2,13 +2,12 @@ # # SPDX-License-Identifier: MIT -"""Rung 13: transmission losses in tangent form — a loss per line, stated by `pypsa_losses.yaml`.""" +"""Rung 13: transmission losses in tangent form — a loss per line.""" from __future__ import annotations import spine -MODEL = 'pypsa_losses.yaml' OPTIMIZE = {'transmission_losses': {'mode': 'tangents', 'segments': 2}} diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 269b1c21..6f1b60f3 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -127,6 +127,11 @@ names: Line_s_nom_set: '\mathrm{s}^{\mathrm{nom,set}}' Line_s_set: '\mathrm{s}^{\mathrm{set}}' Line_cycle_weight: '\mathrm{x}' + transmission_losses: '\mathrm{lossy}' + Line_loss: '\ell' + Line_loss_max: '\overline{\ell}' + Line_loss_slope: '\mathrm{a}' + Line_loss_offset: '\mathrm{b}' Transformer_s: '\sigma' Transformer_s_nom_ext: '\Sigma' Transformer_s_nom: '\sigma^{\mathrm{nom}}' diff --git a/examples/symbols/pypsa_losses.yaml b/examples/symbols/pypsa_losses.yaml deleted file mode 100644 index f9dee95d..00000000 --- a/examples/symbols/pypsa_losses.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -# How `examples/pypsa_losses.yaml` prints — the line symbols of `pypsa.yaml`, plus the loss and its fan. -notation: latex -dimensions: - link: { index: l, set: '\mathcal{L}' } - link_output: { index: o, set: '\mathcal{O}' } - snapshot: { index: t, set: '\mathcal{T}' } - bus: { index: n, set: '\mathcal{N}' } - generator: { index: g, set: '\mathcal{G}' } - load: { index: d, set: '\mathcal{D}' } - line: { index: k, set: '\mathcal{K}' } - cycle: { index: c, set: '\mathcal{C}' } - segment: { index: k, set: '\mathcal{K}' } -names: - Link_p: f - Link_p_nom: '\mathrm{f}^{\mathrm{nom}}' - Link_p_min_pu: '\underline{\mathrm{f}}' - Link_p_max_pu: '\overline{\mathrm{f}}' - Link_efficiency: '\eta' - Link_marginal_cost: '\mathrm{c}^{f}' - snapshot_weightings_objective: '\mathrm{w}' - Generator_p: p - Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' - Generator_p_min_pu: '\underline{\mathrm{p}}' - Generator_p_max_pu: '\overline{\mathrm{p}}' - Generator_marginal_cost: '\mathrm{c}' - Load_p_set: '\mathrm{load}' - Line_s: s - Line_s_nom_ext: S - Line_s_nom: '\mathrm{s}^{\mathrm{nom}}' - Line_s_nom_extendable: '\mathrm{ext}^{s}' - Line_s_max_pu: '\overline{\mathrm{s}}' - Line_s_nom_min: '\underline{\mathrm{s}}^{\mathrm{nom}}' - Line_s_nom_max: '\overline{\mathrm{s}}^{\mathrm{nom}}' - Line_capital_cost: '\mathrm{c}^{\mathrm{cap},s}' - Line_cycle_weight: '\mathrm{x}' - Line_loss: '\ell' - Line_loss_max: '\overline{\ell}' - Line_loss_slope: '\mathrm{a}' - Line_loss_offset: '\mathrm{b}' diff --git a/mkdocs.yml b/mkdocs.yml index acdf2310..4ded694e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,7 +64,6 @@ nav: - One construct per model: examples/operators.md - PyPSA in one file: examples/pypsa.md - PyPSA, the relaxed commitment: examples/pypsa_linearized_uc.md - - PyPSA, the lossy lines: examples/pypsa_losses.md # Everything a reader does not need in order to write a model: the design # arguments, how to contribute, what changed. - About: @@ -175,7 +174,7 @@ markdown_extensions: permalink: "#" toc_depth: 3 # GitHub's slug rules, not mkdocs'. Every page here is read in both - # places and they cross-link by anchor — `pypsa_losses.md#rung-13 + # places and they cross-link by anchor — `pypsa.md#rung-13 # --transmission-losses`, whose double hyphen is an em dash GitHub # drops. The default slugifier collapses that to one, so such a link # would 404 on the site while working in the repository. Same file, diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index c93d05a4..1dd05b56 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -422,6 +422,17 @@ parameters: Line_expansion_cost_weight: - global_constraint - line + Line_loss_max: + - snapshot + - line + Line_loss_offset: + - snapshot + - line + - segment + Line_loss_slope: + - snapshot + - line + - segment Line_s_max_pu: - snapshot - line @@ -673,6 +684,7 @@ parameters: - snapshot snapshot_weightings_stores: - snapshot + transmission_losses: [] relations: - Generator_bus - Line_bus0 diff --git a/tools/gallery.py b/tools/gallery.py index fc36a48c..1d53c077 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -45,7 +45,6 @@ DECLARED = { 'pypsa.md': ROOT / 'examples' / 'pypsa.yaml', 'pypsa_linearized_uc.md': ROOT / 'examples' / 'pypsa_linearized_uc.yaml', - 'pypsa_losses.md': ROOT / 'examples' / 'pypsa_losses.yaml', } #: One PyPSA reference network per rung, run out of band with the versions From 02c5974453a1739f2d82f6f18ecbebf657f34d27 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 20:54:41 +0200 Subject: [PATCH 05/75] docs(pypsa): the spec covers the secant loss mode --- docs/examples/pypsa.md | 47 +++++++++++++++++++++++++++--------------- examples/pypsa.yaml | 32 +++++++++++++++++++--------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 94b1f84b..6f14bf6a 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1155,11 +1155,20 @@ def build(): ### Rung 13 — transmission losses -`n.optimize(transmission_losses={'mode': 'tangents', 'segments': K})`: a line -dissipates a loss its flow buys along a fan of `K` tangents to the quadratic -loss curve, half charged at either end of the line. The loss variable, its cap -and the tangent rows exist only where `transmission_losses` is on; the tangent -slopes and offsets are data prep, one per `segment`. A plain run leaves the flag +`n.optimize(transmission_losses=...)`: a line dissipates a loss its flow buys, +held above a fan of cuts to the quadratic loss curve `r_pu_eff * p**2`, half +charged at either end of the line. PyPSA has two modes, and both build the same +rows `loss + slope * flow >= offset` and `loss - slope * flow >= offset`, one +pair per cut: `{'mode': 'tangents', 'segments': K}` takes `K` tangents at +`p_k = k / K` of the rating, slope `2 r p_k`; `True`, or +`{'mode': 'secants', 'atol': 1, 'rtol': 0.1, 'max_segments': 20}`, takes the +secants between consecutive breakpoints `p_k, p_k+1`, slope `r (p_k + p_k+1)` +and offset `-r p_k p_k+1`, the breakpoints placed from `p_0 = 0` by a step +`max(k / (k - 1), 1 + 2 (rtol + sqrt(rtol + rtol**2)))` until the rating is +covered (`constraints.py:2545`). The mode therefore only decides how data prep +fills `Line_loss_slope` and `Line_loss_offset` over the `segment` axis, and the +breakpoint loop is data prep with them. The loss variable, its cap and the cut +rows exist only where `transmission_losses` is on. A plain run leaves the flag off and supplies no segments, so the loss is absent and reads as zero in the balance, and the model collapses to the lossless one. @@ -1168,10 +1177,10 @@ balance, and the model collapses to the lossless one. | [`Line-loss`](#variable-domains) | done | absent, and zero in the balance, where lossless | | [`Line-fix-s-*`, `Line-ext-s-*`](#line-fix-s-lower) | done | the loss counted against the rating | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | half of each incident line's loss at either end | -| [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep | +| [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep, see X4 | | [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | | [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1) | split | | -| `Line-loss_secants-*` | out | the secant mode solves for its segment count | +| [`Line-loss_secants-pos`, `Line-loss_secants-neg`](#line-loss_tangents-k-1) | split | the same two blocks, secant slope and offset and the breakpoint loop as data prep; no reference records this mode yet | > ✔ `pypsa 1.3.0` solves this rung's network at objective `10645.295879552297`, 150 rows. @@ -1651,7 +1660,8 @@ data prep, or harness — is one open question. Line numbers are pinned pypsa | `ValueError`, `constraints.py:1557` | load on a bus with nothing attached | row not built, unserved | X2 | | `ValueError`, `optimize.py:436` | no component carries a cost | feasibility problem | X3 | | `NotImplementedError`, `global_constraints.py:457` | depletion with period weightings `!= 1` | out | | -| `ValueError`/`RuntimeError`, losses | `s_nom_max = inf`; secant cap | out | | +| `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` | X4 | +| `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | Duals and solutions are read back by the harness on the lpspec side: `marginal_price` is the balance dual over `w_objective`, `mu_upper` the @@ -1680,7 +1690,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | | $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -| $`\mathcal{B}`$ | index $`b`$ — `segment` — the tangents a line's loss curve is approximated by — PyPSA's `transmission_losses` count; none in a lossless run | +| $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a line's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | | $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | @@ -1817,10 +1827,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | | $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | | $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | -| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` count read as a flag, its tangents the `segment` axis; false with no segments is a lossless run | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run | | $`\overline{\ell}`$ | `Line_loss_max` over $`\mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | -| $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep | -| $`\mathrm{b}`$ | `Line_loss_offset` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep | +| $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | +| $`\mathrm{b}`$ | `Line_loss_offset` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\mathcal{M}`$ — nominal apparent power | | $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | | $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | @@ -1870,7 +1880,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | -| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the tangents; absent, and zero in the balance, where the network is lossless | +| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | @@ -2994,9 +3004,10 @@ Line_loss_upper: ```yaml Line_loss_tangents_forward: description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its - curve for flow one way; PyPSA names one row per segment `k`, this block - states them all over the segment dimension + `Line-loss_tangents-{k}-1` — the loss sits above every cut to its curve + for flow one way; PyPSA names one row per tangent `k`, or the one row + `Line-loss_secants-pos` over its `secant` axis, this block states them + all over the segment dimension dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset @@ -3012,7 +3023,9 @@ Line_loss_tangents_forward: ```yaml Line_loss_tangents_reverse: - description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" + description: >- + `Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending + on the flow's magnitude; `Line-loss_secants-neg` in the secant mode dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index e85e1e05..ba1eb746 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -50,7 +50,10 @@ dimensions: cycle: description: independent cycles of the passive network graph — the cycle basis, data prep segment: - description: the tangents a line's loss curve is approximated by — PyPSA's `transmission_losses` count; none in a lossless run + description: >- + the cuts a line's loss curve is held above — PyPSA's tangents, as many as + its `segments` count, or its secants, as many as its tolerance loop + places; none in a lossless run dtype: int global_constraint: description: PyPSA's `GlobalConstraint` rows, one label per declared limit @@ -504,18 +507,24 @@ parameters: transmission_losses: description: >- whether the network dissipates transmission losses — PyPSA's - `transmission_losses` count read as a flag, its tangents the `segment` - axis; false with no segments is a lossless run + `transmission_losses` read as a flag; its mode, tangents or secants, + only decides how data prep fills the `segment` axis, the rows are the + same; false with no segments is a lossless run dims: [] dtype: bool Line_loss_max: description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep dims: [snapshot, line] Line_loss_slope: - description: the slope of a tangent to the loss curve at its segment's flow — `2 * r_pu_eff * p_k`, data prep + description: >- + the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` + at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between + consecutive breakpoints, data prep dims: [snapshot, line, segment] Line_loss_offset: - description: where that tangent meets the loss axis — `loss_k - slope_k * p_k`, negative, data prep + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep dims: [snapshot, line, segment] Transformer_s_nom: description: nominal apparent power @@ -789,7 +798,7 @@ variables: Line_loss: description: >- `Line-loss` — what a line dissipates carrying its flow, pushed down by the - cost and held up by the tangents; absent, and zero in the balance, where + cost and held up by the cuts; absent, and zero in the balance, where the network is lossless dims: [scenario, snapshot, line] where: transmission_losses AND Line_active @@ -1407,14 +1416,17 @@ constraints: expression: Line_loss <= Line_loss_max Line_loss_tangents_forward: description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every tangent to its - curve for flow one way; PyPSA names one row per segment `k`, this block - states them all over the segment dimension + `Line-loss_tangents-{k}-1` — the loss sits above every cut to its curve + for flow one way; PyPSA names one row per tangent `k`, or the one row + `Line-loss_secants-pos` over its `secant` axis, this block states them + all over the segment dimension dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset Line_loss_tangents_reverse: - description: "`Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending on the flow's magnitude" + description: >- + `Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending + on the flow's magnitude; `Line-loss_secants-neg` in the secant mode dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset From 0ce824733c9e35cfcaf9bd16cd6dfdaa61c7dbd0 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 21:05:16 +0200 Subject: [PATCH 06/75] docs(pypsa): the secant loss mode carries a solved reference rung_19 records the secant-mode triangle solved through the pinned pypsa, objective 10840.93, the same 150 rows as the tangent rung. The two loss-cut blocks now stand for both their tangent and secant PyPSA names, matched by a gallery helper that reads every backticked name before the dash. --- docs/examples/pypsa.md | 65 +++++++++++++++++-- examples/pypsa.yaml | 10 +-- examples/references/pypsa/references.json | 31 +++++++++ .../pypsa/rung_19_losses_secants.py | 36 ++++++++++ tests/test_pypsa_references.py | 10 ++- tools/gallery.py | 21 ++++-- 6 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 examples/references/pypsa/rung_19_losses_secants.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 6f14bf6a..be7bd474 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1180,7 +1180,7 @@ balance, and the model collapses to the lossless one. | [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep, see X4 | | [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | | [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1) | split | | -| [`Line-loss_secants-pos`, `Line-loss_secants-neg`](#line-loss_tangents-k-1) | split | the same two blocks, secant slope and offset and the breakpoint loop as data prep; no reference records this mode yet | +| [`Line-loss_secants-pos`, `Line-loss_secants-neg`](#line-loss_tangents-k-1) | done | the same two blocks in the secant mode; slope, offset and the breakpoint loop are data prep; rung 19 records it | > ✔ `pypsa 1.3.0` solves this rung's network at objective `10645.295879552297`, 150 rows. @@ -1232,6 +1232,59 @@ def build(): +The same triangle solved in the secant mode records the identical loss rows, +its cuts placed by PyPSA's tolerance loop rather than fixed per segment. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10840.926895402912`, 150 rows. + +
+The network, as PyPSA code + +`rung_19_losses_secants.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 19: transmission losses in secant form — the same loss per line, its cuts placed by PyPSA's tolerance loop.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'secants', 'atol': 1, 'rtol': 0.1, 'max_segments': 20}} + + +def build(): + """Rung 13's 110 kV triangle, unchanged, so the two modes differ only in the cuts.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro19', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel19', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab19', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Line', 'bc19', bus0='b', bus1='c', carrier='AC', x=60, r=9.7, s_nom=60) + n.add( + 'Line', + 'ca19', + bus0='c', + bus1='a', + carrier='AC', + x=45, + r=6, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + ) + n.add('Load', 'town19', bus='c', p_set=[35, 55, 15, 45]) + return n +``` + +
+ + ### Rung 14 — two-stage stochastic Two futures and a risk preference: `n.set_scenarios(...)` with @@ -3004,9 +3057,9 @@ Line_loss_upper: ```yaml Line_loss_tangents_forward: description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every cut to its curve - for flow one way; PyPSA names one row per tangent `k`, or the one row - `Line-loss_secants-pos` over its `secant` axis, this block states them + `Line-loss_tangents-{k}-1`, `Line-loss_secants-pos` — the loss sits above + every cut to its curve for flow one way; PyPSA names one row per tangent + `k`, or one row stacked over its `secant` axis, and this block states them all over the segment dimension dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active @@ -3024,8 +3077,8 @@ Line_loss_tangents_forward: ```yaml Line_loss_tangents_reverse: description: >- - `Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending - on the flow's magnitude; `Line-loss_secants-neg` in the secant mode + `Line-loss_tangents-{k}--1`, `Line-loss_secants-neg` — the same fan + mirrored, the loss depending on the flow's magnitude dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index ba1eb746..efbb2c8a 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -1416,17 +1416,17 @@ constraints: expression: Line_loss <= Line_loss_max Line_loss_tangents_forward: description: >- - `Line-loss_tangents-{k}-1` — the loss sits above every cut to its curve - for flow one way; PyPSA names one row per tangent `k`, or the one row - `Line-loss_secants-pos` over its `secant` axis, this block states them + `Line-loss_tangents-{k}-1`, `Line-loss_secants-pos` — the loss sits above + every cut to its curve for flow one way; PyPSA names one row per tangent + `k`, or one row stacked over its `secant` axis, and this block states them all over the segment dimension dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset Line_loss_tangents_reverse: description: >- - `Line-loss_tangents-{k}--1` — the same fan mirrored, the loss depending - on the flow's magnitude; `Line-loss_secants-neg` in the secant mode + `Line-loss_tangents-{k}--1`, `Line-loss_secants-neg` — the same fan + mirrored, the loss depending on the flow's magnitude dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index ac9fd5dd..11dfd56a 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -643,5 +643,36 @@ "Transformer-s_nom_set": 1, "Transformer-s_set": 1 } + }, + "rung_19_losses_secants": { + "columns": { + "Generator-p": 16, + "Line-loss": 12, + "Line-s": 12, + "Line-s_nom": 1, + "Link-p": 4, + "objective_constant": 1 + }, + "global_constraints": {}, + "objective": 10840.926895402912, + "objective_constant": 160.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Kirchhoff-Voltage-Law": 4, + "Line-ext-s-lower": 4, + "Line-ext-s-upper": 4, + "Line-ext-s_nom-lower": 1, + "Line-ext-s_nom-upper": 1, + "Line-fix-s-lower": 8, + "Line-fix-s-upper": 8, + "Line-loss_secants-neg": 24, + "Line-loss_secants-pos": 24, + "Line-loss_upper": 12, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_19_losses_secants.py b/examples/references/pypsa/rung_19_losses_secants.py new file mode 100644 index 00000000..81fedb1d --- /dev/null +++ b/examples/references/pypsa/rung_19_losses_secants.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 19: transmission losses in secant form — the same loss per line, its cuts placed by PyPSA's tolerance loop.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'secants', 'atol': 1, 'rtol': 0.1, 'max_segments': 20}} + + +def build(): + """Rung 13's 110 kV triangle, unchanged, so the two modes differ only in the cuts.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro19', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel19', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab19', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Line', 'bc19', bus0='b', bus1='c', carrier='AC', x=60, r=9.7, s_nom=60) + n.add( + 'Line', + 'ca19', + bus0='c', + bus1='a', + carrier='AC', + x=45, + r=6, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + ) + n.add('Load', 'town19', bus='c', p_set=[35, 55, 15, 45]) + return n diff --git a/tests/test_pypsa_references.py b/tests/test_pypsa_references.py index 3e1fcabd..aae55ab7 100644 --- a/tests/test_pypsa_references.py +++ b/tests/test_pypsa_references.py @@ -19,7 +19,7 @@ from math_spec import to_spec from tools import gallery -from tools.gallery import DECLARED, RECORDED, REFERENCES, _stands_for +from tools.gallery import DECLARED, RECORDED, REFERENCES, _names_for, _stands_for RUNGS = sorted(path.stem for path in REFERENCES.glob('rung_*.py')) SCRIPT = REFERENCES / 'reference.py' @@ -28,8 +28,12 @@ SPECS = {page: to_spec(path) for page, path in DECLARED.items()} MODELS = list(SPECS.values()) BASE = SPECS['pypsa.md'] -ROWS_DECLARED = {_stands_for(name, block.description) for m in MODELS for name, block in m.constraints.items()} -COLUMNS_DECLARED = {_stands_for(name, block.description) for m in MODELS for name, block in m.variables.items()} +ROWS_DECLARED = { + n for m in MODELS for name, block in m.constraints.items() for n in _names_for(name, block.description) +} +COLUMNS_DECLARED = { + n for m in MODELS for name, block in m.variables.items() for n in _names_for(name, block.description) +} #: The five GlobalConstraint formulas open with their *type* — PyPSA names #: those rows after each row's own label, so they are matched through the #: recorded type and sense instead of by name. diff --git a/tools/gallery.py b/tools/gallery.py index 1d53c077..5234db78 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -86,15 +86,26 @@ def declaration(text: str, section: str, name: str | None = None) -> str: return textwrap.dedent('\n'.join(lines[i:j])).rstrip() -def _stands_for(name: str, description: str | None) -> str: - """The other side's name for a declaration — the backticked opening of its description.""" - found = re.match(r'`([^`]+)`', description or '') - if found is None: +def _names_for(name: str, description: str | None) -> list[str]: + """Every other-side name a declaration stands for: the backticked tokens before the ` — ` of its description. + + One declaration answers to one PyPSA name as a rule; a block whose rows PyPSA + names differently by mode lists them all before the dash, the first canonical. + """ + text = description or '' + head = text.split(' — ', 1)[0] if ' — ' in text else (re.match(r'`[^`]+`', text) or [''])[0] + names = re.findall(r'`([^`]+)`', head) + if not names: msg = ( f'{name}: a declaration on a declared page opens its description with the name it stands for, in backticks' ) raise ValueError(msg) - return found.group(1) + return names + + +def _stands_for(name: str, description: str | None) -> str: + """The other side's canonical name for a declaration — the backticked opening of its description.""" + return _names_for(name, description)[0] def declared_block(path: Path) -> str: From b0fba215be13dd5398125d7b9420b39b83f0bd5d Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 22 Sep 2026 21:26:55 +0200 Subject: [PATCH 07/75] docs(pypsa): the spec covers the optimised transformer phase shift A phase-shifting transformer's angle becomes a per-snapshot decision where phase_shift_min < phase_shift_max, bounded by them and entering the KVL cycle sum in place of the fixed constant; a plain run keeps the shift fixed and collapses byte-for-byte. rung_20 records the phase-shifter triangle solved through the pinned pypsa, objective 16455.0. --- docs/examples/pypsa.md | 101 +++++++++++++++--- examples/pypsa.yaml | 55 ++++++++-- examples/references/pypsa/references.json | 25 +++++ .../references/pypsa/rung_20_phase_shifter.py | 38 +++++++ tests/fixtures/pypsa_standard_shape.yaml | 9 ++ 5 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 examples/references/pypsa/rung_20_phase_shifter.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index be7bd474..66e0310b 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1607,12 +1607,12 @@ def build(): ### Rung 18 — transformer A transformer is a passive branch between two buses, as a line is, but its flow -follows its effective series reactance and a fixed phase shift. It obeys the -Kirchhoff voltage law (KVL) around every independent cycle, so it builds no flow -outside a mesh. A non-extendable transformer carries a fixed nominal apparent -power. An extendable one chooses it between `s_nom_min` and `s_nom_max`. An -`s_set` fixes a flow schedule. An `s_nom_set` fixes an extendable transformer's -built capacity. +follows its effective series reactance and a phase shift, fixed or optimised. It +obeys the Kirchhoff voltage law (KVL) around every independent cycle, so it +builds no flow outside a mesh. A non-extendable transformer carries a fixed +nominal apparent power. An extendable one chooses it between `s_nom_min` and +`s_nom_max`. An `s_set` fixes a flow schedule. An `s_nom_set` fixes an extendable +transformer's built capacity. | PyPSA | status | note | | --- | --- | --- | @@ -1621,6 +1621,7 @@ built capacity. | [`Transformer-s_set`](#transformer-s_set) | done | a fixed flow schedule | | [`Transformer-s_nom_set`](#transformer-s_nom_set) | done | a fixed built capacity | | [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) | done | rung 6, over `x_pu_eff` and a phase shift | +| [`Transformer-phase_shift`](#variable-domains) | done | rung 20, an optimised phase shift | | [objective](#objective) | done | capital on capacity | @@ -1700,6 +1701,67 @@ def build(): +### Rung 20 — phase shifter + +A phase-shifting transformer's voltage angle shift is a per-snapshot decision +where its `phase_shift_min` sits below its `phase_shift_max`, bounded between the +two in degrees. The shift enters the same KVL cycle sum as a fixed one, so it +redistributes the flows around a cycle without moving active power. Here the +shift holds the transformer at its rating while the upstream unit serves the +whole varying load, and the fixed `phase_shift` gives way to it. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `16455.0`, 88 rows. + +
+The network, as PyPSA code + +`rung_20_phase_shifter.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 20: phase shifter — a transformer whose per-snapshot phase shift is optimised, holding it at its rating and rerouting the surplus around the cycle. + +The parallel lines carry low reactance, so a few degrees of shift move tens of +megawatts: the phase-shifting transformer keeps its flow at its ``s_nom`` while +the upstream hydro serves the whole varying load, and the costly local unit +stays off. A fixed shift could not follow the load, so the shift is a decision. +""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a triangle where a phase-shifting transformer reroutes cheap power around a binding leg, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c']) + n.add('Generator', 'hydro20', bus='a', p_nom=300, marginal_cost=10) + n.add('Generator', 'diesel20', bus='c', p_nom=300, marginal_cost=200) + n.add('Load', 'town20', bus='c', p_set=[90, 75, 120, 105]) + n.add('Line', 'ab20', bus0='a', bus1='b', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Line', 'bc20', bus0='b', bus1='c', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add( + 'Transformer', + 'ca20', + bus0='c', + bus1='a', + x=0.002, + r=0.0002, + s_nom=40, + phase_shift_min=-30, + phase_shift_max=30, + ) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -1741,7 +1803,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | | $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | | $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a line's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | @@ -1893,7 +1955,11 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | | $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | | $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row | -| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's fixed phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; a transformer with no shift or in no cycle has no row. PyPSA also admits an optimisable phase shift, a later rung this file does not carry | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | +| $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift` | +| $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | +| $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | +| $`\mathrm{Transformer\_phase\_shift\_cycle\_weight}`$ | `Transformer_phase_shift_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the cycle sign for a varying transformer's phase shift, times π/180 so a shift in degrees enters the cycle sum in radians — data prep; zero for a fixed transformer or one in no cycle | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{I}`$ — which way the row binds — `<=`, `>=` or `==` | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | @@ -1935,6 +2001,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | +| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ — `Link-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | @@ -1968,6 +2035,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | +Upright is what the model is given — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. + $`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. @@ -3226,17 +3295,19 @@ Kirchhoff_Voltage_Law: `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear power flow physical rather than transport. A transformer's flow weighs its - effective reactance, and its fixed phase shift enters the cycle sum as a - constant + effective reactance, and its phase shift enters the cycle sum too: a + constant where the shift is fixed, or the shift decision times its cycle + weight where the shift is a phase-shifting transformer's to choose dims: [scenario, snapshot, cycle] expression: >- sum(Line_s * Line_cycle_weight, over=line) + sum(Transformer_s * Transformer_cycle_weight, over=transformer) - + sum(Transformer_phase_shift_weight, over=transformer) == 0 + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) == 0 ``` ```math -\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} + \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} + \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} + \sum_{m \in \mathcal{M}} \mathit{Transformer\_phase\_shift}_{\xi,t,m} \cdot \mathrm{Transformer\_phase\_shift\_cycle\_weight}_{m,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` ### `Generator-p-ramp_limit_up` @@ -4507,6 +4578,12 @@ s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \sigma_{\xi,t,m} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{on}^{\sigma}_{t,m} ``` +**`Transformer_phase_shift`** + +```math +\mathrm{Transformer\_phase\_shift\_min}_{m} \le \mathit{Transformer\_phase\_shift}_{\xi,t,m} \le \mathrm{Transformer\_phase\_shift\_max}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{Transformer\_phase\_shift\_varying}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + **`Line_s_nom_ext`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index efbb2c8a..d7c751aa 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -46,7 +46,7 @@ dimensions: line: description: passive branches, each between two buses, their flow set by impedance transformer: - description: passive branches between two buses, their flow set by impedance and tap ratio, with a fixed phase shift + description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised cycle: description: independent cycles of the passive network graph — the cycle basis, data prep segment: @@ -559,10 +559,35 @@ parameters: dims: [transformer, cycle] Transformer_phase_shift_weight: description: >- - the transformer's fixed phase shift in radians, signed by its orientation - in the cycle — a constant added to the cycle sum, data prep; a transformer - with no shift or in no cycle has no row. PyPSA also admits an optimisable - phase shift, a later rung this file does not carry + a fixed transformer's phase shift in radians, signed by its orientation + in the cycle — a constant added to the cycle sum, data prep; zero for a + varying transformer, whose shift is a decision instead, so the constant and + the variable term never both count a shift. A transformer with no shift or + in no cycle has no row + dims: [transformer, cycle] + Transformer_phase_shift_varying: + description: >- + whether a transformer's phase shift is a decision — PyPSA's + `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a + fixed shift carried by `phase_shift` + dims: [transformer] + dtype: bool + Transformer_phase_shift_min: + description: >- + the least a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is + a decision, otherwise the transformer keeps its fixed `phase_shift` + dims: [transformer] + Transformer_phase_shift_max: + description: >- + the most a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer + dims: [transformer] + Transformer_phase_shift_cycle_weight: + description: >- + the cycle sign for a varying transformer's phase shift, times π/180 so a + shift in degrees enters the cycle sum in radians — data prep; zero for a + fixed transformer or one in no cycle dims: [transformer, cycle] GlobalConstraint_type: description: >- @@ -812,6 +837,18 @@ variables: `Transformer_bus1`, lossless dims: [scenario, snapshot, transformer] where: Transformer_active + Transformer_phase_shift: + description: >- + `Transformer-phase_shift` — a phase-shifting transformer's voltage angle + shift in degrees, chosen per snapshot to redistribute the flows around its + cycles without moving active power; absent, and zero in the cycle sum, + where the shift is fixed + dims: [scenario, snapshot, transformer] + where: Transformer_phase_shift_varying AND Transformer_active + absence: zero + bounds: + lower: Transformer_phase_shift_min + upper: Transformer_phase_shift_max Line_s_nom_ext: description: >- `Line-s_nom` — nominal apparent power where it is a decision; the @@ -1475,13 +1512,15 @@ constraints: `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear power flow physical rather than transport. A transformer's flow weighs its - effective reactance, and its fixed phase shift enters the cycle sum as a - constant + effective reactance, and its phase shift enters the cycle sum too: a + constant where the shift is fixed, or the shift decision times its cycle + weight where the shift is a phase-shifting transformer's to choose dims: [scenario, snapshot, cycle] expression: >- sum(Line_s * Line_cycle_weight, over=line) + sum(Transformer_s * Transformer_cycle_weight, over=transformer) - + sum(Transformer_phase_shift_weight, over=transformer) == 0 + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) == 0 Generator_p_ramp_limit_up: description: >- `Generator-p-ramp_limit_up` — a generator raises output no faster than diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 11dfd56a..c124d27c 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -674,5 +674,30 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_20_phase_shifter": { + "columns": { + "Generator-p": 16, + "Line-s": 8, + "Link-p": 4, + "Transformer-phase_shift": 4, + "Transformer-s": 4 + }, + "global_constraints": {}, + "objective": 16455.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Kirchhoff-Voltage-Law": 4, + "Line-fix-s-lower": 8, + "Line-fix-s-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_20_phase_shifter.py b/examples/references/pypsa/rung_20_phase_shifter.py new file mode 100644 index 00000000..efe88b1f --- /dev/null +++ b/examples/references/pypsa/rung_20_phase_shifter.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 20: phase shifter — a transformer whose per-snapshot phase shift is optimised, holding it at its rating and rerouting the surplus around the cycle. + +The parallel lines carry low reactance, so a few degrees of shift move tens of +megawatts: the phase-shifting transformer keeps its flow at its ``s_nom`` while +the upstream hydro serves the whole varying load, and the costly local unit +stays off. A fixed shift could not follow the load, so the shift is a decision. +""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a triangle where a phase-shifting transformer reroutes cheap power around a binding leg, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c']) + n.add('Generator', 'hydro20', bus='a', p_nom=300, marginal_cost=10) + n.add('Generator', 'diesel20', bus='c', p_nom=300, marginal_cost=200) + n.add('Load', 'town20', bus='c', p_set=[90, 75, 120, 105]) + n.add('Line', 'ab20', bus0='a', bus1='b', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Line', 'bc20', bus0='b', bus1='c', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add( + 'Transformer', + 'ca20', + bus0='c', + bus1='a', + x=0.002, + r=0.0002, + s_nom=40, + phase_shift_min=-30, + phase_shift_max=30, + ) + return n diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index 1dd05b56..7e66c9bd 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -657,6 +657,15 @@ parameters: Transformer_cycle_weight: - transformer - cycle + Transformer_phase_shift_cycle_weight: + - transformer + - cycle + Transformer_phase_shift_max: + - transformer + Transformer_phase_shift_min: + - transformer + Transformer_phase_shift_varying: + - transformer Transformer_phase_shift_weight: - transformer - cycle From c125c565addfbf59411ba470ab3721a8725c573e Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 09:21:51 +0200 Subject: [PATCH 08/75] docs(pypsa): the carrier growth limit binds every extendable component --- docs/examples/pypsa.md | 159 ++++++++++++++++-- examples/pypsa.yaml | 68 +++++++- examples/references/pypsa/references.json | 40 ++++- .../pypsa/rung_21_carrier_growth.py | 69 ++++++++ examples/symbols/pypsa.yaml | 6 + 5 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 examples/references/pypsa/rung_21_carrier_growth.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 66e0310b..7556d91b 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -205,7 +205,7 @@ def build(): | [capital cost](#objective) | done | `periodized_cost` is an annuity, data prep | -> ✔ `pypsa 1.3.0` solves this rung's network at objective `7633.908502024291`, 184 rows. +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7633.908502024292`, 184 rows.
The network, as PyPSA code @@ -419,10 +419,10 @@ each type is three blocks by sense. | [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) | split | a block per sense | | [`tech_capacity_expansion_limit`](#tech_capacity_expansion_limit) | split | a block per sense | | `Bus-nom_min/max_{carrier}` | out | deprecated in PyPSA | -| [`Carrier-growth_limit`](#carrier-growth_limit) | done | rung 15, below | +| [`Carrier-growth_limit`](#carrier-growth_limit) | done | generators in rung 15, every extendable component in rung 21, below | -> ✔ `pypsa 1.3.0` solves this rung's network at objective `10282.833333333332`, 102 rows. +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10282.833333333334`, 102 rows.
The network, as PyPSA code @@ -1354,7 +1354,7 @@ own dimensions. | --- | --- | --- | | [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | -| [`Carrier-growth_limit`](#carrier-growth_limit) | done | counted in the first period a build stands in; `edge=0` at the first period | +| [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | `StorageUnit-energy_balance` per period, ramps at period starts | out | `shift(…, by=snapshot_period, within=period)` has them; a later rung | @@ -1762,6 +1762,99 @@ def build():
+### Rung 21 — carrier growth + +A carrier's `max_growth` caps what it adds in an investment period, across every +extendable component of that carrier, not the generators alone. Here a battery +carrier caps a storage unit and a store built in the first period, and the two +builds fill the cap together. A store built in the later period adds its +allowance plus half of what the carrier added before. PyPSA counts the +components that carry a carrier attribute; the spec extends the same limit to +transformers, which PyPSA leaves out. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `8452.5`, 74 rows. + +
+The network, as PyPSA code + +`rung_21_carrier_growth.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 21: a carrier's growth limit binds its non-generator builds — a storage unit and two stores over two periods.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: cheap solar only at the first snapshot of each period, so battery capacity pays off but its growth is capped.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'solar') + n.add('Carrier', 'gas') + n.add('Carrier', 'battery', max_growth=20, max_relative_growth=0.5) + n.add('Generator', 'solar', bus='grid', carrier='solar', p_nom=100, marginal_cost=1, p_max_pu=[1, 0, 1, 0]) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=200, marginal_cost=80) + n.add( + 'StorageUnit', + 'bat20', + bus='grid', + carrier='battery', + p_nom_extendable=True, + p_nom_max=100, + max_hours=4, + capital_cost=50, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank20', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=10, + capital_cost=10, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank30', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=8, + build_year=2030, + lifetime=30, + ) + n.add('Load', 'town', bus='grid', p_set=[40, 60, 40, 80]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -1795,20 +1888,20 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — network nodes | | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | | $`\mathcal{R}`$ | index $`r`$ — `process_output` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — a process's ports, one label per port a process declares — PyPSA's `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number of ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | -| $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | -| $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | -| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | +| $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | | $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a line's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | -| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | #### Parameters @@ -1883,6 +1976,12 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{W}^{z}`$ | `Process_capital_weight` over $`\mathcal{J}`$ — the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{\sigma}`$ | `Transformer_capital_weight` over $`\mathcal{M}`$ — the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{f}`$ | `Link_first_active` over $`\mathcal{Y} \times \mathcal{L}`$ — one in the first period a link stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{h}`$ | `StorageUnit_first_active` over $`\mathcal{Y} \times \mathcal{S}`$ — one in the first period a storage unit stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}^{\sigma}`$ | `Transformer_first_active` over $`\mathcal{Y} \times \mathcal{M}`$ — one in the first period a transformer stands in, zero elsewhere — the spec extends the carrier growth limit to transformers, which PyPSA does not, so PyPSA has no counterpart, data prep | | $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top | | $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | @@ -2034,6 +2133,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. PyPSA sums the components that carry a carrier attribute; the transformer term is the spec's own extension, since PyPSA gives a transformer no carrier | Upright is what the model is given — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -4108,20 +4208,20 @@ Bus_nodal_balance: ```yaml Carrier_growth_limit: description: >- - `Carrier-growth_limit` — what a carrier adds in a period, counting each build in the first - period it stands in, is at most its allowance plus a share of what it added the period before; - the first period has no predecessor, so `edge=0` leaves it the bare allowance + `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, + counting each build in the first period it stands in, is at most its allowance plus a share of + what it added the period before; the first period has no predecessor, so `edge=0` leaves it the + bare allowance dims: [carrier, period] where: Carrier_max_growth expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) - * Carrier_max_relative_growth + Carrier_additions + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_max_relative_growth <= Carrier_max_growth ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} - \left( \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y \boxminus_{0} 1,g} \right) \cdot \mathrm{r}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} +\mathit{Carrier\_additions}_{y,i} - \mathit{Carrier\_additions}_{y \boxminus_{0} 1,i} \cdot \mathrm{r}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} ``` ### `CVaR-excess-{s}` @@ -4480,6 +4580,29 @@ scenario_opex: \mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +### `Carrier_additions` + +```yaml +Carrier_additions: + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. PyPSA sums + the components that carry a carrier attribute; the transformer term is the + spec's own extension, since PyPSA gives a transformer no carrier + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + + sum(Transformer_s_nom_ext * Transformer_first_active, by=Transformer_carrier, over=transformer, into=carrier) +``` + +```math +\mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_carrier}(m) = i} \Sigma_{m} \cdot \mathrm{new}^{\sigma}_{y,m} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + #### Variable domains **`Generator_p`** diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index d7c751aa..594ff0b1 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -320,6 +320,24 @@ parameters: Generator_first_active: description: one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep dims: [period, generator] + Link_first_active: + description: one in the first period a link stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, link] + StorageUnit_first_active: + description: one in the first period a storage unit stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, storage_unit] + Store_first_active: + description: one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, store] + Line_first_active: + description: one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, line] + Process_first_active: + description: one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + dims: [period, process] + Transformer_first_active: + description: one in the first period a transformer stands in, zero elsewhere — the spec extends the carrier growth limit to transformers, which PyPSA does not, so PyPSA has no counterpart, data prep + dims: [period, transformer] Carrier_max_growth: description: most capacity of a carrier that may be added in a period; no value means no limit dims: [carrier] @@ -678,6 +696,10 @@ relations: description: the bus a generator sits on key: generator values: bus + Link_carrier: + description: the carrier a link converts from + key: link + values: carrier Link_bus0: description: the bus a link leaves key: link @@ -693,6 +715,10 @@ relations: relation, so the file states any number of them key: link_output values: bus + Process_carrier: + description: the carrier a process converts from + key: process + values: carrier Process_output_process: description: the process a port belongs to key: process_output @@ -708,10 +734,18 @@ relations: description: the bus a load sits on key: load values: bus + StorageUnit_carrier: + description: the carrier a storage unit converts from + key: storage_unit + values: carrier StorageUnit_bus: description: the bus a storage unit sits on key: storage_unit values: bus + Line_carrier: + description: the carrier a line carries + key: line + values: carrier Line_bus0: description: the bus a line's flow is measured at key: line @@ -720,10 +754,18 @@ relations: description: the bus at a line's other end key: line values: bus + Store_carrier: + description: the carrier a store holds + key: store + values: carrier Store_bus: description: the bus a store sits on key: store values: bus + Transformer_carrier: + description: the carrier a transformer carries + key: transformer + values: carrier Transformer_bus0: description: the bus a transformer's flow is measured at key: transformer @@ -1077,6 +1119,20 @@ expressions: + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + Carrier_additions: + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. PyPSA sums + the components that carry a carrier attribute; the transformer term is the + spec's own extension, since PyPSA gives a transformer no carrier + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + + sum(Transformer_s_nom_ext * Transformer_first_active, by=Transformer_carrier, over=transformer, into=carrier) constraints: Generator_fix_p_lower: @@ -1808,15 +1864,15 @@ constraints: == sum(Load_p_set, by=Load_bus, over=load, into=bus) Carrier_growth_limit: description: >- - `Carrier-growth_limit` — what a carrier adds in a period, counting each build in the first - period it stands in, is at most its allowance plus a share of what it added the period before; - the first period has no predecessor, so `edge=0` leaves it the bare allowance + `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, + counting each build in the first period it stands in, is at most its allowance plus a share of + what it added the period before; the first period has no predecessor, so `edge=0` leaves it the + bare allowance dims: [carrier, period] where: Carrier_max_growth expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - - shift(sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier), along=period, offset=1, edge=0) - * Carrier_max_relative_growth + Carrier_additions + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_max_relative_growth <= Carrier_max_growth CVaR_excess: description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index c124d27c..cac7b715 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -102,7 +102,7 @@ "type": "transmission_volume_expansion_limit" } }, - "objective": 7633.908502024291, + "objective": 7633.908502024292, "objective_constant": 0.0, "pypsa": "1.3.0", "rows": { @@ -218,7 +218,7 @@ "type": "operational_limit" } }, - "objective": 10282.833333333332, + "objective": 10282.833333333334, "objective_constant": 0.0, "pypsa": "1.3.0", "rows": { @@ -699,5 +699,41 @@ "Transformer-fix-s-lower": 4, "Transformer-fix-s-upper": 4 } + }, + "rung_21_carrier_growth": { + "columns": { + "Generator-p": 8, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_nom": 1, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Store-e": 6, + "Store-e_nom": 2, + "Store-p": 6 + }, + "global_constraints": {}, + "objective": 8452.5, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 4, + "Carrier-growth_limit": 2, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "StorageUnit-energy_balance": 4, + "StorageUnit-ext-p_dispatch-lower": 4, + "StorageUnit-ext-p_dispatch-upper": 4, + "StorageUnit-ext-p_nom-lower": 1, + "StorageUnit-ext-p_nom-upper": 1, + "StorageUnit-ext-p_store-lower": 4, + "StorageUnit-ext-p_store-upper": 4, + "StorageUnit-ext-state_of_charge-lower": 4, + "StorageUnit-ext-state_of_charge-upper": 4, + "Store-energy_balance": 6, + "Store-ext-e-lower": 6, + "Store-ext-e-upper": 6, + "Store-ext-e_nom-lower": 2, + "Store-ext-e_nom-upper": 2 + } } } diff --git a/examples/references/pypsa/rung_21_carrier_growth.py b/examples/references/pypsa/rung_21_carrier_growth.py new file mode 100644 index 00000000..505fa064 --- /dev/null +++ b/examples/references/pypsa/rung_21_carrier_growth.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 21: a carrier's growth limit binds its non-generator builds — a storage unit and two stores over two periods.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: cheap solar only at the first snapshot of each period, so battery capacity pays off but its growth is capped.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'solar') + n.add('Carrier', 'gas') + n.add('Carrier', 'battery', max_growth=20, max_relative_growth=0.5) + n.add('Generator', 'solar', bus='grid', carrier='solar', p_nom=100, marginal_cost=1, p_max_pu=[1, 0, 1, 0]) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=200, marginal_cost=80) + n.add( + 'StorageUnit', + 'bat20', + bus='grid', + carrier='battery', + p_nom_extendable=True, + p_nom_max=100, + max_hours=4, + capital_cost=50, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank20', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=10, + capital_cost=10, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank30', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=8, + build_year=2030, + lifetime=30, + ) + n.add('Load', 'town', bus='grid', p_set=[40, 60, 40, 80]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 6f1b60f3..0612e5da 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -220,5 +220,11 @@ names: Process_capital_weight: '\mathrm{W}^{z}' Transformer_capital_weight: '\mathrm{W}^{\sigma}' Generator_first_active: '\mathrm{new}' + Link_first_active: '\mathrm{new}^{f}' + StorageUnit_first_active: '\mathrm{new}^{h}' + Store_first_active: '\mathrm{new}^{e}' + Line_first_active: '\mathrm{new}^{s}' + Process_first_active: '\mathrm{new}^{z}' + Transformer_first_active: '\mathrm{new}^{\sigma}' Carrier_max_growth: '\overline{\Delta}' Carrier_max_relative_growth: '\mathrm{r}' From 28926f09975b61f85862fad844bc62c9d5a0d9a4 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 09:59:41 +0200 Subject: [PATCH 09/75] docs(pypsa): the spec covers transformer transmission losses A transformer carries its own loss under transmission_losses, as a line does: the loss counted against its rating, its cap, its tangent or secant cuts and half of it at either bus. A plain run collapses byte-for-byte. rung_22 and rung_23 record the transformer triangle in both modes, objectives 10643.48 and 10822.00. --- docs/examples/pypsa.md | 227 ++++++++++++++++-- examples/pypsa.yaml | 74 +++++- examples/references/pypsa/references.json | 84 +++++++ .../pypsa/rung_22_transformer_losses.py | 36 +++ .../rung_23_transformer_losses_secants.py | 36 +++ examples/symbols/pypsa.yaml | 4 + tests/fixtures/pypsa_standard_shape.yaml | 11 + 7 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 examples/references/pypsa/rung_22_transformer_losses.py create mode 100644 examples/references/pypsa/rung_23_transformer_losses_secants.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 7556d91b..16a4b122 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1174,13 +1174,13 @@ balance, and the model collapses to the lossless one. | PyPSA | status | note | | --- | --- | --- | -| [`Line-loss`](#variable-domains) | done | absent, and zero in the balance, where lossless | -| [`Line-fix-s-*`, `Line-ext-s-*`](#line-fix-s-lower) | done | the loss counted against the rating | -| [`Bus-nodal_balance`](#bus-nodal_balance) | done | half of each incident line's loss at either end | -| [`Line-loss_upper`](#line-loss_upper) | done | `loss_max` is data prep, see X4 | -| [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | -| [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1) | split | | -| [`Line-loss_secants-pos`, `Line-loss_secants-neg`](#line-loss_tangents-k-1) | done | the same two blocks in the secant mode; slope, offset and the breakpoint loop are data prep; rung 19 records it | +| [`Line-loss`, `Transformer-loss`](#variable-domains) | done | absent, and zero in the balance, where lossless | +| [`Line-fix-s-*`, `Line-ext-s-*`](#line-fix-s-lower), [`Transformer-fix-s-*`, `Transformer-ext-s-*`](#transformer-fix-s-lower) | done | the loss counted against the rating | +| [`Bus-nodal_balance`](#bus-nodal_balance) | done | half of each incident line's and transformer's loss at either end | +| [`Line-loss_upper`](#line-loss_upper), [`Transformer-loss_upper`](#transformer-loss_upper) | done | `loss_max` is data prep, see X4 | +| [`Line-loss_tangents-{k}-1`](#line-loss_tangents-k-1), [`Transformer-loss_tangents-{k}-1`](#transformer-loss_tangents-k-1) | split | PyPSA names a row per segment; one block over the dimension | +| [`Line-loss_tangents-{k}--1`](#line-loss_tangents-k--1), [`Transformer-loss_tangents-{k}--1`](#transformer-loss_tangents-k--1) | split | | +| [`Line-loss_secants-pos`, `Line-loss_secants-neg`](#line-loss_tangents-k-1), [`Transformer-loss_secants-pos`, `Transformer-loss_secants-neg`](#transformer-loss_tangents-k-1) | done | the same two blocks in the secant mode; slope, offset and the breakpoint loop are data prep; rungs 19 and 23 record it | > ✔ `pypsa 1.3.0` solves this rung's network at objective `10645.295879552297`, 150 rows. @@ -1855,6 +1855,121 @@ def build():
+### Rung 22 — transformer losses + +PyPSA applies the loss of rung 13 to every passive branch, so a transformer +dissipates a loss as a line does: its own loss variable, the loss counted +against its rating, its cap and its fan of cuts, and half of it at either end +in the balance. The loss curve is `r_pu_eff * p**2`, where a transformer's +`r_pu_eff` is its resistance over its given `s_nom`, times its tap ratio +(`power_flow.py:815`). The given `s_nom` sets it also for an extendable +transformer, whose build does not move the curve. Here the loss of the +extendable transformer is counted against its rating, so it builds more than +the flow it carries. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10643.477135410736`, 174 rows. + +
+The network, as PyPSA code + +`rung_22_transformer_losses.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 22: transformer losses in tangent form — a loss per transformer, as per line.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'tangents', 'segments': 3}} + + +def build(): + """The spine plus a triangle of one 110 kV line and two transformers, one extendable and off-nominal tap, per-unit resistances a real transformer has, so its loss stays a few percent of the flow.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro22', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel22', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab22', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Transformer', 'bc22', bus0='b', bus1='c', x=0.1, r=0.03, s_nom=60) + n.add( + 'Transformer', + 'ca22', + bus0='c', + bus1='a', + x=0.12, + r=0.02, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + tap_ratio=1.05, + ) + n.add('Load', 'town22', bus='c', p_set=[35, 55, 15, 45]) + return n +``` + +
+ + +The same triangle solved in the secant mode records the identical loss rows, +its cuts placed by PyPSA's tolerance loop. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10821.999155213578`, 142 rows. + +
+The network, as PyPSA code + +`rung_23_transformer_losses_secants.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 23: transformer losses in secant form — the same loss per transformer, its cuts placed by PyPSA's tolerance loop.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'secants', 'atol': 1, 'rtol': 0.1, 'max_segments': 20}} + + +def build(): + """Rung 22's triangle, unchanged, so the two modes differ only in the cuts.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro23', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel23', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab23', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Transformer', 'bc23', bus0='b', bus1='c', x=0.1, r=0.03, s_nom=60) + n.add( + 'Transformer', + 'ca23', + bus0='c', + bus1='a', + x=0.12, + r=0.02, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + tap_ratio=1.05, + ) + n.add('Load', 'town23', bus='c', p_set=[35, 55, 15, 45]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -1868,7 +1983,7 @@ data prep, or harness — is one open question. Line numbers are pinned pypsa | `ValueError`, `constraints.py:1557` | load on a bus with nothing attached | row not built, unserved | X2 | | `ValueError`, `optimize.py:436` | no component carries a cost | feasibility problem | X3 | | `NotImplementedError`, `global_constraints.py:457` | depletion with period weightings `!= 1` | out | | -| `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` | X4 | +| `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | Duals and solutions are read back by the harness on the lpspec side: @@ -1898,7 +2013,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | | $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -| $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a line's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | +| $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | | $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | @@ -2059,6 +2174,9 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | | $`\mathrm{Transformer\_phase\_shift\_cycle\_weight}`$ | `Transformer_phase_shift_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the cycle sign for a varying transformer's phase shift, times π/180 so a shift in degrees enters the cycle sum in radians — data prep; zero for a fixed transformer or one in no cycle | +| $`\overline{\ell}^{\sigma}`$ | `Transformer_loss_max` over $`\mathcal{T} \times \mathcal{M}`$ — the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times the tap ratio, data prep | +| $`\mathrm{a}^{\sigma}`$ | `Transformer_loss_slope` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep | +| $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{I}`$ — which way the row binds — `<=`, `>=` or `==` | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | @@ -2100,6 +2218,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | +| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | | $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | @@ -3263,14 +3382,14 @@ Line_loss_tangents_reverse: ```yaml Transformer_fix_s_lower: - description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating" + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, transformer] where: not Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom ``` ```math -\sigma_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-fix-s-upper` @@ -3279,14 +3398,14 @@ Transformer_fix_s_lower: ```yaml Transformer_fix_s_upper: - description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating" + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating, the loss included" dims: [scenario, snapshot, transformer] where: not Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom ``` ```math -\sigma_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s-lower` @@ -3295,14 +3414,14 @@ Transformer_fix_s_upper: ```yaml Transformer_ext_s_lower: - description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build" + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build, the loss counted against it" dims: [scenario, snapshot, transformer] where: Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom_ext ``` ```math -\sigma_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s-upper` @@ -3311,14 +3430,14 @@ Transformer_ext_s_lower: ```yaml Transformer_ext_s_upper: - description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build" + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build, the loss included" dims: [scenario, snapshot, transformer] where: Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom_ext ``` ```math -\sigma_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s_nom-lower` @@ -3385,6 +3504,59 @@ Transformer_s_set: \sigma_{\xi,t,m} = \sigma^{\mathrm{set}}_{t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \sigma^{\mathrm{set}}_{t,m} \text{ is defined} \wedge \mathrm{on}^{\sigma}_{t,m} ``` +### `Transformer-loss_upper` + +`Transformer_loss_upper` + +```yaml +Transformer_loss_upper: + description: "`Transformer-loss_upper` — a transformer dissipates at most the loss at its rating" + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + expression: Transformer_loss <= Transformer_loss_max +``` + +```math +\ell^{\sigma}_{\xi,t,m} \le \overline{\ell}^{\sigma}_{t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-loss_tangents-{k}-1` + +`Transformer_loss_tangents_forward` + +```yaml +Transformer_loss_tangents_forward: + description: >- + `Transformer-loss_tangents-{k}-1`, `Transformer-loss_secants-pos` — the + loss sits above every cut to its curve for flow one way, as a line's + does, over the segment dimension + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss + Transformer_loss_slope * Transformer_s >= Transformer_loss_offset +``` + +```math +\ell^{\sigma}_{\xi,t,m} + \mathrm{a}^{\sigma}_{t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +### `Transformer-loss_tangents-{k}--1` + +`Transformer_loss_tangents_reverse` + +```yaml +Transformer_loss_tangents_reverse: + description: >- + `Transformer-loss_tangents-{k}--1`, `Transformer-loss_secants-neg` — the + same fan mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset +``` + +```math +\ell^{\sigma}_{\xi,t,m} - \mathrm{a}^{\sigma}_{t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + ### `Kirchhoff-Voltage-Law` `Kirchhoff_Voltage_Law` @@ -4177,7 +4349,8 @@ Bus_nodal_balance: them after losses and any delay at every port they deliver to, each process port drawing or delivering at its own rate and each passive branch carrying its flow, meets the load there, less half of every incident - line's loss — PyPSA dissipates a branch's loss half at either end. + line's and transformer's loss — PyPSA dissipates a branch's loss half at + either end. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -4194,11 +4367,13 @@ Bus_nodal_balance: - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) + - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` @@ -4701,6 +4876,12 @@ s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \sigma_{\xi,t,m} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{on}^{\sigma}_{t,m} ``` +**`Transformer_loss`** + +```math +\ell^{\sigma}_{\xi,t,m} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + **`Transformer_phase_shift`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 594ff0b1..b5992207 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -51,9 +51,9 @@ dimensions: description: independent cycles of the passive network graph — the cycle basis, data prep segment: description: >- - the cuts a line's loss curve is held above — PyPSA's tangents, as many as - its `segments` count, or its secants, as many as its tolerance loop - places; none in a lossless run + the cuts a passive branch's loss curve is held above — PyPSA's tangents, + as many as its `segments` count, or its secants, as many as its tolerance + loop places; none in a lossless run dtype: int global_constraint: description: PyPSA's `GlobalConstraint` rows, one label per declared limit @@ -607,6 +607,23 @@ parameters: shift in degrees enters the cycle sum in radians — data prep; zero for a fixed transformer or one in no cycle dims: [transformer, cycle] + Transformer_loss_max: + description: >- + the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * + s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times + the tap ratio, data prep + dims: [snapshot, transformer] + Transformer_loss_slope: + description: >- + the slope of a cut to a transformer's loss curve — a tangent's + `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, + over the transformer's own `r_pu_eff` and rating, data prep + dims: [snapshot, transformer, segment] + Transformer_loss_offset: + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep + dims: [snapshot, transformer, segment] GlobalConstraint_type: description: >- which formula the row takes — `primary_energy`, `operational_limit`, @@ -879,6 +896,16 @@ variables: `Transformer_bus1`, lossless dims: [scenario, snapshot, transformer] where: Transformer_active + Transformer_loss: + description: >- + `Transformer-loss` — what a transformer dissipates carrying its flow, as + a line does; absent, and zero in the balance, where the network is + lossless + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + absence: zero + bounds: + lower: 0 Transformer_phase_shift: description: >- `Transformer-phase_shift` — a phase-shifting transformer's voltage angle @@ -1524,25 +1551,25 @@ constraints: where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset Transformer_fix_s_lower: - description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating" + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, transformer] where: not Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom Transformer_fix_s_upper: - description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating" + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating, the loss included" dims: [scenario, snapshot, transformer] where: not Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom Transformer_ext_s_lower: - description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build" + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build, the loss counted against it" dims: [scenario, snapshot, transformer] where: Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom_ext Transformer_ext_s_upper: - description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build" + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build, the loss included" dims: [scenario, snapshot, transformer] where: Transformer_s_nom_extendable AND Transformer_active - expression: Transformer_s <= Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom_ext Transformer_ext_s_nom_lower: description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor" dims: [transformer] @@ -1563,6 +1590,26 @@ constraints: dims: [scenario, snapshot, transformer] where: Transformer_s_set AND Transformer_active expression: Transformer_s == Transformer_s_set + Transformer_loss_upper: + description: "`Transformer-loss_upper` — a transformer dissipates at most the loss at its rating" + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + expression: Transformer_loss <= Transformer_loss_max + Transformer_loss_tangents_forward: + description: >- + `Transformer-loss_tangents-{k}-1`, `Transformer-loss_secants-pos` — the + loss sits above every cut to its curve for flow one way, as a line's + does, over the segment dimension + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss + Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + Transformer_loss_tangents_reverse: + description: >- + `Transformer-loss_tangents-{k}--1`, `Transformer-loss_secants-neg` — the + same fan mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the @@ -1844,7 +1891,8 @@ constraints: them after losses and any delay at every port they deliver to, each process port drawing or delivering at its own rate and each passive branch carrying its flow, meets the load there, less half of every incident - line's loss — PyPSA dissipates a branch's loss half at either end. + line's and transformer's loss — PyPSA dissipates a branch's loss half at + either end. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] @@ -1861,6 +1909,8 @@ constraints: - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) + - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) == sum(Load_p_set, by=Load_bus, over=load, into=bus) Carrier_growth_limit: description: >- diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index cac7b715..47fe75e2 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -735,5 +735,89 @@ "Store-ext-e_nom-lower": 2, "Store-ext-e_nom-upper": 2 } + }, + "rung_22_transformer_losses": { + "columns": { + "Generator-p": 16, + "Line-loss": 4, + "Line-s": 4, + "Link-p": 4, + "Transformer-loss": 8, + "Transformer-s": 8, + "Transformer-s_nom": 1, + "objective_constant": 1 + }, + "global_constraints": {}, + "objective": 10643.477135410736, + "objective_constant": 160.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Kirchhoff-Voltage-Law": 4, + "Line-fix-s-lower": 4, + "Line-fix-s-upper": 4, + "Line-loss_tangents-1--1": 4, + "Line-loss_tangents-1-1": 4, + "Line-loss_tangents-2--1": 4, + "Line-loss_tangents-2-1": 4, + "Line-loss_tangents-3--1": 4, + "Line-loss_tangents-3-1": 4, + "Line-loss_upper": 4, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Transformer-ext-s-lower": 4, + "Transformer-ext-s-upper": 4, + "Transformer-ext-s_nom-lower": 1, + "Transformer-ext-s_nom-upper": 1, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-upper": 4, + "Transformer-loss_tangents-1--1": 8, + "Transformer-loss_tangents-1-1": 8, + "Transformer-loss_tangents-2--1": 8, + "Transformer-loss_tangents-2-1": 8, + "Transformer-loss_tangents-3--1": 8, + "Transformer-loss_tangents-3-1": 8, + "Transformer-loss_upper": 8 + } + }, + "rung_23_transformer_losses_secants": { + "columns": { + "Generator-p": 16, + "Line-loss": 4, + "Line-s": 4, + "Link-p": 4, + "Transformer-loss": 8, + "Transformer-s": 8, + "Transformer-s_nom": 1, + "objective_constant": 1 + }, + "global_constraints": {}, + "objective": 10821.999155213578, + "objective_constant": 160.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Kirchhoff-Voltage-Law": 4, + "Line-fix-s-lower": 4, + "Line-fix-s-upper": 4, + "Line-loss_secants-neg": 4, + "Line-loss_secants-pos": 4, + "Line-loss_upper": 4, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Transformer-ext-s-lower": 4, + "Transformer-ext-s-upper": 4, + "Transformer-ext-s_nom-lower": 1, + "Transformer-ext-s_nom-upper": 1, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-upper": 4, + "Transformer-loss_secants-neg": 16, + "Transformer-loss_secants-pos": 16, + "Transformer-loss_upper": 8 + } } } diff --git a/examples/references/pypsa/rung_22_transformer_losses.py b/examples/references/pypsa/rung_22_transformer_losses.py new file mode 100644 index 00000000..ac33c34a --- /dev/null +++ b/examples/references/pypsa/rung_22_transformer_losses.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 22: transformer losses in tangent form — a loss per transformer, as per line.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'tangents', 'segments': 3}} + + +def build(): + """The spine plus a triangle of one 110 kV line and two transformers, one extendable and off-nominal tap, per-unit resistances a real transformer has, so its loss stays a few percent of the flow.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro22', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel22', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab22', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Transformer', 'bc22', bus0='b', bus1='c', x=0.1, r=0.03, s_nom=60) + n.add( + 'Transformer', + 'ca22', + bus0='c', + bus1='a', + x=0.12, + r=0.02, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + tap_ratio=1.05, + ) + n.add('Load', 'town22', bus='c', p_set=[35, 55, 15, 45]) + return n diff --git a/examples/references/pypsa/rung_23_transformer_losses_secants.py b/examples/references/pypsa/rung_23_transformer_losses_secants.py new file mode 100644 index 00000000..7421dc83 --- /dev/null +++ b/examples/references/pypsa/rung_23_transformer_losses_secants.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 23: transformer losses in secant form — the same loss per transformer, its cuts placed by PyPSA's tolerance loop.""" + +from __future__ import annotations + +import spine + +OPTIMIZE = {'transmission_losses': {'mode': 'secants', 'atol': 1, 'rtol': 0.1, 'max_segments': 20}} + + +def build(): + """Rung 22's triangle, unchanged, so the two modes differ only in the cuts.""" + n = spine.build() + n.add('Bus', ['a', 'b', 'c'], v_nom=110) + n.add('Generator', 'hydro23', bus='a', p_nom=80, marginal_cost=10) + n.add('Generator', 'diesel23', bus='b', p_nom=80, marginal_cost=50) + n.add('Line', 'ab23', bus0='a', bus1='b', carrier='AC', x=30, r=6, s_nom=60) + n.add('Transformer', 'bc23', bus0='b', bus1='c', x=0.1, r=0.03, s_nom=60) + n.add( + 'Transformer', + 'ca23', + bus0='c', + bus1='a', + x=0.12, + r=0.02, + s_nom=40, + s_nom_extendable=True, + s_nom_max=90, + capital_cost=4, + tap_ratio=1.05, + ) + n.add('Load', 'town23', bus='c', p_set=[35, 55, 15, 45]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 0612e5da..16aca2d0 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -144,6 +144,10 @@ names: Transformer_s_set: '\sigma^{\mathrm{set}}' Transformer_cycle_weight: '\mathrm{x}^{\sigma}' Transformer_phase_shift_weight: '\vartheta' + Transformer_loss: '\ell^{\sigma}' + Transformer_loss_max: '\overline{\ell}^{\sigma}' + Transformer_loss_slope: '\mathrm{a}^{\sigma}' + Transformer_loss_offset: '\mathrm{b}^{\sigma}' GlobalConstraint_type: '\mathrm{type}' GlobalConstraint_sense: '\mathrm{sense}' GlobalConstraint_constant: '\mathrm{K}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index 7e66c9bd..abf708ac 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -657,6 +657,17 @@ parameters: Transformer_cycle_weight: - transformer - cycle + Transformer_loss_max: + - snapshot + - transformer + Transformer_loss_offset: + - snapshot + - transformer + - segment + Transformer_loss_slope: + - snapshot + - transformer + - segment Transformer_phase_shift_cycle_weight: - transformer - cycle From cdcc0415199cf5033498993a2b1c0280529bc258 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 10:01:37 +0200 Subject: [PATCH 10/75] docs(pypsa): a committable unit serving its brought-in down time stays off Generator_com_status_must_stay_down mirrors the must-stay-up block over a data-prep mask, status fixed to zero; PyPSA names the row Generator-com-status-min_down_time_must_stay_up. rung_24 records a cheap unit held off for two snapshots, objective 9007.5. --- docs/examples/pypsa.md | 79 ++++++++++++++++++- examples/pypsa.yaml | 19 ++++- examples/references/pypsa/references.json | 29 +++++++ .../pypsa/rung_24_must_stay_down.py | 29 +++++++ examples/symbols/pypsa.yaml | 1 + tests/fixtures/pypsa_standard_shape.yaml | 6 ++ 6 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 examples/references/pypsa/rung_24_must_stay_down.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 16a4b122..d734d66f 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -601,7 +601,8 @@ def build(): | [`{c}-*-p-fixed-upper`](#generator-status-p-fixed-upper) | done | status, start and stop each at most one, as explicit rows | | [`{c}-com-transition-start-up/shut-down`](#generator-com-transition-start-up) | done | the state carried into a snapshot is a cased quantity, so the first snapshot needs no block of its own | | [`{c}-com-up-time`, `-down-time`](#generator-com-up-time) | done | `sum_back(window=min_up_time)` | -| [`{c}-com-status-*-must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | +| [`{c}-com-status-min_up_time_must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | +| [`{c}-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | the same prep mask over the down time brought in, status zero; PyPSA's name says `_must_stay_up`; rung 24 records it | | [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | | [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rung 12, a file of its own | @@ -1970,6 +1971,58 @@ def build(): +### Rung 24 — must stay down + +A committable unit that stopped `down_time_before` snapshots before the horizon +stays off until its `min_down_time` has passed. PyPSA fixes its status to zero +in the first `min_down_time - down_time_before` snapshots. Here the cheapest +unit in the network brought one snapshot of a three-snapshot down time into +the horizon, so it stays off for two snapshots and the dearer coal unit serves +the load. + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `9007.5`, 65 rows. + +
+The network, as PyPSA code + +`rung_24_must_stay_down.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 24: must stay down — a committable unit still serving the down time it brought in stays off.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a cheap committable unit that stopped one snapshot before the horizon and must stay off for three.""" + n = spine.build() + n.add( + 'Generator', + 'warm', + bus='north', + committable=True, + p_nom=50, + marginal_cost=5, + p_min_pu=0.2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add('Load', 'swing24', bus='north', p_set=[25, 45, 45, 10]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2038,6 +2091,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\mathcal{G}`$ — cost of one start | | $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\mathcal{G}`$ — cost of one stop | | $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | @@ -2839,7 +2893,10 @@ Generator_com_up_time: ```yaml Generator_com_down_time: - description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status @@ -2865,6 +2922,24 @@ Generator_com_status_must_stay_up: u_{\xi,t,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{t,g} \wedge \mathrm{on}_{t,g} ``` +### `Generator-com-status-min_down_time_must_stay_up` + +`Generator_com_status_must_stay_down` + +```yaml +Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_down AND Generator_active + expression: Generator_status == 0 +``` + +```math +u_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rest}_{t,g} \wedge \mathrm{on}_{t,g} +``` + ### `Generator-p-ramp_limit_up-run-bigM` `Generator_p_ramp_limit_up_run_big_m` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index b5992207..bd1fe383 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -121,6 +121,13 @@ parameters: parameter dims: [snapshot, generator] dtype: bool + Generator_must_stay_down: + description: >- + true while the down time a unit brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [snapshot, generator] + dtype: bool Generator_start_up_cost: description: cost of one start dims: [generator] @@ -1338,7 +1345,10 @@ constraints: where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status Generator_com_down_time: - description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status @@ -1347,6 +1357,13 @@ constraints: dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_must_stay_up AND Generator_active expression: Generator_status == 1 + Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_down AND Generator_active + expression: Generator_status == 0 Generator_p_ramp_limit_up_run_big_m: description: >- `Generator-p-ramp_limit_up-run-bigM` — a committed extendable unit diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 47fe75e2..c44f97ff 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -819,5 +819,34 @@ "Transformer-loss_secants-pos": 16, "Transformer-loss_upper": 8 } + }, + "rung_24_must_stay_down": { + "columns": { + "Generator-p": 12, + "Generator-shut_down": 4, + "Generator-start_up": 4, + "Generator-status": 4, + "Link-p": 4 + }, + "global_constraints": {}, + "objective": 9007.5, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-com-down-time": 3, + "Generator-com-p-lower": 4, + "Generator-com-p-upper": 4, + "Generator-com-status-min_down_time_must_stay_up": 2, + "Generator-com-transition-shut-down": 4, + "Generator-com-transition-start-up": 4, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Generator-shut_down-p-fixed-upper": 4, + "Generator-start_up-p-fixed-upper": 4, + "Generator-status-p-fixed-upper": 4, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_24_must_stay_down.py b/examples/references/pypsa/rung_24_must_stay_down.py new file mode 100644 index 00000000..47295e34 --- /dev/null +++ b/examples/references/pypsa/rung_24_must_stay_down.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 24: must stay down — a committable unit still serving the down time it brought in stays off.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a cheap committable unit that stopped one snapshot before the horizon and must stay off for three.""" + n = spine.build() + n.add( + 'Generator', + 'warm', + bus='north', + committable=True, + p_nom=50, + marginal_cost=5, + p_min_pu=0.2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add('Load', 'swing24', bus='north', p_set=[25, 45, 45, 10]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 16aca2d0..e46daf0d 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -47,6 +47,7 @@ names: Generator_ramp_up_allowance: '\Delta^{+}' Generator_ramp_down_allowance: '\Delta^{-}' Generator_must_stay_up: '\mathrm{hold}' + Generator_must_stay_down: '\mathrm{rest}' Generator_start_up_cost: '\mathrm{c}^{\mathrm{up}}' Generator_shut_down_cost: '\mathrm{c}^{\mathrm{dn}}' Generator_stand_by_cost: '\mathrm{c}^{\mathrm{on}}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index abf708ac..c5acc4ce 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -37,6 +37,9 @@ constraints: Generator_com_p_upper: - snapshot - generator + Generator_com_status_must_stay_down: + - snapshot + - generator Generator_com_status_must_stay_up: - snapshot - generator @@ -356,6 +359,9 @@ parameters: - generator Generator_modules_installed: - generator + Generator_must_stay_down: + - snapshot + - generator Generator_must_stay_up: - snapshot - generator From a4baa2cfe1259f71550a673a18b4209db9b20373 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 10:19:25 +0200 Subject: [PATCH 11/75] docs(pypsa): a committable link carries the generator's unit commitment Link gains the status, start-up and shut-down variables, the committable, big-M and modular p bounds, transitions, up and down times, both must-stay rules, the committed ramp rows, n_mod and its costs, mirroring Generator. rung_25 records five committable links, objective 14013.0. --- docs/examples/pypsa.md | 777 +++++++++++++++++- examples/pypsa.yaml | 370 ++++++++- examples/references/pypsa/references.json | 51 ++ .../pypsa/rung_25_committable_link.py | 89 ++ examples/symbols/pypsa.yaml | 23 + tests/fixtures/pypsa_standard_shape.yaml | 122 +++ tests/test_pypsa_collapses.py | 1 + 7 files changed, 1401 insertions(+), 32 deletions(-) create mode 100644 examples/references/pypsa/rung_25_committable_link.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index d734d66f..5e212b5d 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -596,7 +596,7 @@ def build(): | PyPSA | status | note | | -------------------------------------------- | ------ | ------------------------------------------------------------- | -| [`{c}-status`, `-start_up`, `-shut_down`](#variable-domains) | done | Generator; a committable link is not taken up here | +| [`{c}-status`, `-start_up`, `-shut_down`](#variable-domains) | done | Generator; Link in rung 25 | | [`{c}-com-p-lower/upper`](#generator-com-p-lower) | done | | | [`{c}-*-p-fixed-upper`](#generator-status-p-fixed-upper) | done | status, start and stop each at most one, as explicit rows | | [`{c}-com-transition-start-up/shut-down`](#generator-com-transition-start-up) | done | the state carried into a snapshot is a cased quantity, so the first snapshot needs no block of its own | @@ -2023,6 +2023,134 @@ def build(): +### Rung 25 — committable links + +A committable link carries the generator's whole unit commitment over its flow. +PyPSA builds the same status, transition, up time, down time, must-stay, +big-M, modular and ramp rows for a `Link` as for a `Generator`, and prices its +starts, stops and stand-by snapshots the same way. Here an east bus is served +only by committable links. `hvdc` brought one snapshot of a three-snapshot up +time into the horizon, so it stays on for two snapshots although a cheaper link +could carry the load. `cold_tie` brought one snapshot of a three-snapshot down +time, so it stays off for two snapshots. Its own two-snapshot up time would +then hold it on into the last snapshot, where the load is below its minimum, so +it does not start at all. The other links are committable builds that are +extendable, modular, or both. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Link-status`, `-start_up`, `-shut_down`, `-n_mod`](#variable-domains) | done | as the generator's, rung 7 and 8 | +| [`Link-com-p-*`, `-com-mod-p-*`, `-com-ext-p-*`](#link-com-p-lower) | done | a committable link leaves the `Link-fix-p-*` and `Link-ext-p-*` rows, as a generator does | +| [`Link-*-p-fixed-upper`, `-*-p_nom-variable-upper`](#link-status-p-fixed-upper) | done | | +| [`Link-com-transition-*`, `-com-up-time`, `-com-down-time`](#link-com-transition-start-up) | done | | +| [`Link-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#link-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | +| [`Link-p-ramp_limit_*`, `-*-bigM`](#link-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over flow | +| [`Link-p_nom_modularity`](#link-p_nom_modularity) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `14013.0`, 235 rows. + +
+The network, as PyPSA code + +`rung_25_committable_link.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 25: committable links — a link held on by the up time it brought in, one held off by its down time, one kept on by its own up time, and committable builds that are extendable, modular or both.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus that only committable links serve.""" + n = spine.build() + n.add('Bus', 'east') + n.add( + 'Link', + 'hvdc', + bus0='north', + bus1='east', + committable=True, + p_nom=60, + p_min_pu=0.3, + marginal_cost=8, + min_up_time=3, + min_down_time=2, + up_time_before=1, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ramp_limit_start_up=0.6, + ramp_limit_shut_down=0.6, + start_up_cost=100, + shut_down_cost=50, + stand_by_cost=5, + ) + n.add( + 'Link', + 'cold_tie', + bus0='north', + bus1='east', + committable=True, + p_nom=40, + p_min_pu=0.2, + min_up_time=2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add( + 'Link', + 'ext_tie', + bus0='north', + bus1='east', + committable=True, + p_nom_extendable=True, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ) + n.add( + 'Link', + 'mod_tie', + bus0='south', + bus1='east', + committable=True, + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=40, + capital_cost=3, + p_min_pu=0.5, + ) + n.add( + 'Link', + 'mod_fix', + bus0='north', + bus1='east', + committable=True, + p_nom=20, + p_nom_mod=10, + p_min_pu=0.5, + marginal_cost=1, + ) + n.add('Load', 'east_load', bus='east', p_set=[20, 70, 60, 5]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2110,6 +2238,21 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the horizon's end — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at the first snapshots is lost | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | +| $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | +| $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\mathcal{L}`$ — least snapshots a link stays on once started | +| $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\mathcal{L}`$ — least snapshots a link stays off once stopped | +| $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\mathcal{L}`$ — cost of one start | +| $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\mathcal{L}`$ — cost of one stop | +| $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\mathcal{L}`$ — a bound safely above any feasible flow — the build cap at full availability, data prep | +| $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | | $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | | $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | @@ -2269,6 +2412,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | +| $`N^{f}`$ | `Link_n_mod` over $`\mathcal{L}`$ — `Link-n_mod` — how many modules of an extendable modular build | +| $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-status` — how much of a committable link is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is | +| $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | @@ -2295,6 +2442,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — nothing at the start of the horizon, which is why a link that came in running carries no ramp row there | +| $`\Delta^{f,+}`$ | `Link_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may raise flow between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | @@ -2378,12 +2529,12 @@ p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" dims: [scenario, snapshot, link] - where: not Link_p_nom_extendable AND Link_active + where: not Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-fix-p-upper` @@ -2394,12 +2545,12 @@ f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" dims: [scenario, snapshot, link] - where: not Link_p_nom_extendable AND Link_active + where: not Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Generator-ext-p-lower` @@ -2522,12 +2673,12 @@ Generator_e_sum_max: Link_ext_p_lower: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, link] - where: Link_p_nom_extendable AND Link_active + where: Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom_ext ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p-upper` @@ -2538,12 +2689,12 @@ f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \i Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" dims: [scenario, snapshot, link] - where: Link_p_nom_extendable AND Link_active + where: Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom_ext ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p_nom-lower` @@ -3269,6 +3420,471 @@ Generator_shut_down_p_nom_variable_upper: \mathit{dn}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` +### `Link-com-p-lower` + +`Link_com_p_lower` + +```yaml +Link_com_p_lower: + description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * Link_status +``` + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-p-upper` + +`Link_com_p_upper` + +```yaml +Link_com_p_upper: + description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * Link_status +``` + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-transition-start-up` + +`Link_com_transition_start_up` + +```yaml +Link_com_transition_start_up: + description: "`Link-com-transition-start-up` — turning on is a start, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_start_up >= Link_status - Link_previous_status +``` + +```math +\mathit{up}^{f}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-transition-shut-down` + +`Link_com_transition_shut_down` + +```yaml +Link_com_transition_shut_down: + description: "`Link-com-transition-shut-down` — turning off is a stop, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_shut_down >= Link_previous_status - Link_status +``` + +```math +\mathit{dn}^{f}_{\xi,t,l} \ge \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-up-time` + +`Link_com_up_time` + +```yaml +Link_com_up_time: + description: >- + `Link-com-up-time` — a link started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_up_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_start_up, along=snapshot, window=Link_min_up_time) <= Link_status +``` + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{f}} \mathit{up}^{f}_{\xi,t',l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{UT}^{f}_{l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-down-time` + +`Link_com_down_time` + +```yaml +Link_com_down_time: + description: >- + `Link-com-down-time` — a link stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_down_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_shut_down, along=snapshot, window=Link_min_down_time) <= 1 - Link_status +``` + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{f}} \mathit{dn}^{f}_{\xi,t',l} \le 1 - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{DT}^{f}_{l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-status-min_up_time_must_stay_up` + +`Link_com_status_must_stay_up` + +```yaml +Link_com_status_must_stay_up: + description: "`Link-com-status-min_up_time_must_stay_up` — a link still serving the up time it brought in stays on" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_up AND Link_active + expression: Link_status == 1 +``` + +```math +u^{f}_{\xi,t,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{hold}^{f}_{t,l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-status-min_down_time_must_stay_up` + +`Link_com_status_must_stay_down` + +```yaml +Link_com_status_must_stay_down: + description: >- + `Link-com-status-min_down_time_must_stay_up` — a link still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_down AND Link_active + expression: Link_status == 0 +``` + +```math +u^{f}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{rest}^{f}_{t,l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-p-ramp_limit_up-run-bigM` + +`Link_p_ramp_limit_up_run_big_m` + +```yaml +Link_p_ramp_limit_up_run_big_m: + description: >- + `Link-p-ramp_limit_up-run-bigM` — a committed extendable link + raises flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_limit_up * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_previous_status +``` + +```math +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \mathrm{ru}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-p-ramp_limit_up-start-bigM` + +`Link_p_ramp_limit_up_start_big_m` + +```yaml +Link_p_ramp_limit_up_start_big_m: + description: >- + `Link-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable link ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_limit_start_up * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_start_up +``` + +```math +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \mathrm{ru}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-p-ramp_limit_down-run-bigM` + +`Link_p_ramp_limit_down_run_big_m` + +```yaml +Link_p_ramp_limit_down_run_big_m: + description: >- + `Link-p-ramp_limit_down-run-bigM` — a committed extendable link + lowers flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_limit_down * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_status +``` + +```math +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \mathrm{rd}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-p-ramp_limit_down-shut-bigM` + +`Link_p_ramp_limit_down_shut_big_m` + +```yaml +Link_p_ramp_limit_down_shut_big_m: + description: >- + `Link-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable link ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_limit_shut_down * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_shut_down +``` + +```math +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \mathrm{rd}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-p_nom_modularity` + +`Link_p_nom_modularity` + +```yaml +Link_p_nom_modularity: + description: "`Link-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + expression: Link_p_nom_ext == Link_p_nom_mod * Link_n_mod +``` + +```math +F_{l} = \mathrm{f}^{\mathrm{mod}}_{l} \cdot N^{f}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + +### `Link-com-ext-p-upper-cap` + +`Link_com_ext_p_upper_cap` + +```yaml +Link_com_ext_p_upper_cap: + description: >- + `Link-com-ext-p-upper-cap` — a committed extendable link flows + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_ext +``` + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-ext-p-upper-bigM` + +`Link_com_ext_p_upper_big_m` + +```yaml +Link_com_ext_p_upper_big_m: + description: "`Link-com-ext-p-upper-bigM` — off, a link flows nothing; on, the big M is no bound" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_big_m * Link_status +``` + +```math +f_{\xi,t,l} \le \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-ext-p-lower` + +`Link_com_ext_p_lower` + +```yaml +Link_com_ext_p_lower: + description: >- + `Link-com-ext-p-lower` — a committed extendable link flows at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: >- + Link_p >= + Link_p_min_pu * Link_p_nom_ext + + Link_big_m * Link_status - Link_big_m +``` + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} + \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-ext-p-lower-nonneg` + +`Link_com_ext_p_lower_nonneg` + +```yaml +Link_com_ext_p_lower_nonneg: + description: >- + `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, flow is also plainly non-negative, a row the big-M lower + cannot assert while the link is off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable + AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p >= 0 +``` + +```math +f_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{nonneg}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-mod-p-lower` + +`Link_com_mod_p_lower` + +```yaml +Link_com_mod_p_lower: + description: >- + `Link-com-mod-p-lower` — a committed modular link flows at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * Link_status +``` + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-mod-p-upper` + +`Link_com_mod_p_upper` + +```yaml +Link_com_mod_p_upper: + description: >- + `Link-com-mod-p-upper` — a committed modular link flows at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * Link_status +``` + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-status-p-fixed-upper` + +`Link_status_p_fixed_upper` + +```yaml +Link_status_p_fixed_upper: + description: >- + `Link-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_status <= Link_modules_installed +``` + +```math +u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-start_up-p-fixed-upper` + +`Link_start_up_p_fixed_upper` + +```yaml +Link_start_up_p_fixed_upper: + description: >- + `Link-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_start_up <= Link_modules_installed +``` + +```math +\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-shut_down-p-fixed-upper` + +`Link_shut_down_p_fixed_upper` + +```yaml +Link_shut_down_p_fixed_upper: + description: >- + `Link-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_shut_down <= Link_modules_installed +``` + +```math +\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-status-p_nom-variable-upper` + +`Link_status_p_nom_variable_upper` + +```yaml +Link_status_p_nom_variable_upper: + description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_status <= Link_n_mod +``` + +```math +u^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-start_up-p_nom-variable-upper` + +`Link_start_up_p_nom_variable_upper` + +```yaml +Link_start_up_p_nom_variable_upper: + description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_start_up <= Link_n_mod +``` + +```math +\mathit{up}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-shut_down-p_nom-variable-upper` + +`Link_shut_down_p_nom_variable_upper` + +```yaml +Link_shut_down_p_nom_variable_upper: + description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_shut_down <= Link_n_mod +``` + +```math +\mathit{dn}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + ### `Line-fix-s-lower` `Line_fix_s_lower` @@ -3712,16 +4328,21 @@ Generator_p_ramp_limit_down: ```yaml Link_p_ramp_limit_up: description: >- - `Link-p-ramp_limit_up` — a link raises flow no faster than its limit of - the build. The translated term vacates the first snapshot, where a plain - optimize builds no row either + `Link-p-ramp_limit_up` — a link raises flow no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A link that came into the + horizon running brought an unknown flow, so it carries no row at the + first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] - where: Link_ramp_limit_up AND Link_active - expression: Link_p - shift(Link_p, along=snapshot, offset=1) <= Link_ramp_limit_up * Link_p_nom_effective + where: >- + Link_ramp_limit_up + AND NOT (Link_committable AND Link_p_nom_extendable) + AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + expression: Link_p - Link_previous_p <= Link_ramp_up_allowance ``` ```math -f_{\xi,t,l} - f_{\xi,t - 1,l} \le \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -3730,14 +4351,22 @@ f_{\xi,t,l} - f_{\xi,t - 1,l} \le \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f ```yaml Link_p_ramp_limit_down: - description: "`Link-p-ramp_limit_down` — a link lowers flow no faster than its limit of the build" + description: >- + `Link-p-ramp_limit_down` — a link lowers flow no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A link that came into the + horizon running brought an unknown flow, so it carries no row at the + first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] - where: Link_ramp_limit_down AND Link_active - expression: shift(Link_p, along=snapshot, offset=1) - Link_p <= Link_ramp_limit_down * Link_p_nom_effective + where: >- + Link_ramp_limit_down + AND NOT (Link_committable AND Link_p_nom_extendable) + AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + expression: Link_previous_p - Link_p <= Link_ramp_down_allowance ``` ```math -f_{\xi,t - 1,l} - f_{\xi,t,l} \le \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -4615,6 +5244,87 @@ Link_p_nom_effective: \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} = \begin{cases} F_{l} & \text{if } \mathrm{ext}^{f}_{l} \\ \mathrm{f}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} ``` +### `Link_previous_status` + +```yaml +Link_previous_status: + description: >- + the commitment state a link carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial } + otherwise: shift(Link_status, along=snapshot, offset=1) +``` + +```math +\overleftarrow{u}^{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{l} & \text{if } \mathrm{pos}(t) = 0 \\ u^{f}_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +### `Link_previous_p` + +```yaml +Link_previous_p: + description: >- + the flow a link carries into a snapshot — nothing at the start of + the horizon, which is why a link that came in running carries no ramp row + there + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: 0 } + otherwise: shift(Link_p, along=snapshot, offset=1) +``` + +```math +\overleftarrow{f}_{\xi,t,l} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ f_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +### `Link_ramp_up_allowance` + +```yaml +Link_ramp_up_allowance: + description: >- + how far a link may raise flow between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_limit_up * Link_p_nom * Link_previous_status + + Link_ramp_limit_start_up * Link_p_nom + * (Link_status - Link_previous_status) + otherwise: Link_ramp_limit_up * Link_p_nom_effective +``` + +```math +\Delta^{f,+}_{\xi,t,l} = \begin{cases} \mathrm{ru}^{f}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \mathrm{ru}^{f,\mathrm{up}}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +### `Link_ramp_down_allowance` + +```yaml +Link_ramp_down_allowance: + description: >- + how far a link may lower flow between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_limit_down * Link_p_nom * Link_status + + Link_ramp_limit_shut_down * Link_p_nom + * (Link_previous_status - Link_status) + otherwise: Link_ramp_limit_down * Link_p_nom_effective +``` + +```math +\Delta^{f,-}_{\xi,t,l} = \begin{cases} \mathrm{rd}^{f}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} + \mathrm{rd}^{f,\mathrm{dn}}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + ### `Process_p_nom_effective` ```yaml @@ -4824,10 +5534,13 @@ scenario_opex: + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` ### `Carrier_additions` @@ -4933,6 +5646,30 @@ u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \ \mathit{dn}_{\xi,t,g} \ge 0, \mathit{dn}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` +**`Link_n_mod`** + +```math +N^{f}_{l} \ge 0, N^{f}_{l} \in \mathbb{Z} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + +**`Link_status`** + +```math +u^{f}_{\xi,t,l} \ge 0, u^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_start_up`** + +```math +\mathit{up}^{f}_{\xi,t,l} \ge 0, \mathit{up}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_shut_down`** + +```math +\mathit{dn}^{f}_{\xi,t,l} \ge 0, \mathit{dn}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + **`Line_s`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index bd1fe383..3e9846a4 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -201,6 +201,70 @@ parameters: Link_marginal_cost_quadratic: description: cost of the square of one unit of flow dims: [snapshot, link] + Link_committable: + description: whether flow is gated by an on/off status decision + dims: [link] + dtype: bool + Link_ramp_limit_start_up: + description: most flow in the snapshot a link starts, per unit of nominal power + dims: [link] + Link_ramp_limit_shut_down: + description: most flow in the snapshot before a link stops, per unit of nominal power + dims: [link] + Link_min_up_time: + description: least snapshots a link stays on once started + dims: [link] + dtype: int + Link_min_down_time: + description: least snapshots a link stays off once stopped + dims: [link] + dtype: int + Link_status_initial: + description: one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [link] + dtype: int + Link_must_stay_up: + description: >- + true while the up time a link brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [snapshot, link] + dtype: bool + Link_must_stay_down: + description: >- + true while the down time a link brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [snapshot, link] + dtype: bool + Link_start_up_cost: + description: cost of one start + dims: [link] + Link_shut_down_cost: + description: cost of one stop + dims: [link] + Link_stand_by_cost: + description: cost of one snapshot spent on + dims: [snapshot, link] + Link_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [link] + Link_modules_installed: + description: >- + how many whole modules a committable build has in place: `Link_p_nom + / Link_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [link] + Link_big_m: + description: a bound safely above any feasible flow — the build cap at full availability, data prep + dims: [link] + Link_p_min_pu_nonneg: + description: >- + true where none of the link's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep + dims: [link] + dtype: bool Process_p_nom: description: nominal internal power dims: [process] @@ -880,6 +944,37 @@ variables: domain: integer bounds: lower: 0 + Link_n_mod: + description: "`Link-n_mod` — how many modules of an extendable modular build" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Link_status: + description: >- + `Link-status` — how much of a committable link is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_start_up: + description: "`Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_shut_down: + description: "`Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 Line_s: description: >- `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a @@ -1037,6 +1132,51 @@ expressions: cases: extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } otherwise: Link_p_nom + Link_previous_status: + description: >- + the commitment state a link carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial } + otherwise: shift(Link_status, along=snapshot, offset=1) + Link_previous_p: + description: >- + the flow a link carries into a snapshot — nothing at the start of + the horizon, which is why a link that came in running carries no ramp row + there + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: 0 } + otherwise: shift(Link_p, along=snapshot, offset=1) + Link_ramp_up_allowance: + description: >- + how far a link may raise flow between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_limit_up * Link_p_nom * Link_previous_status + + Link_ramp_limit_start_up * Link_p_nom + * (Link_status - Link_previous_status) + otherwise: Link_ramp_limit_up * Link_p_nom_effective + Link_ramp_down_allowance: + description: >- + how far a link may lower flow between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_limit_down * Link_p_nom * Link_status + + Link_ramp_limit_shut_down * Link_p_nom + * (Link_previous_status - Link_status) + otherwise: Link_ramp_limit_down * Link_p_nom_effective Process_p_nom_effective: description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise dims: [process] @@ -1153,6 +1293,9 @@ expressions: + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) Carrier_additions: description: >- what a carrier adds in a period — every extendable component of that @@ -1182,12 +1325,12 @@ constraints: Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" dims: [scenario, snapshot, link] - where: not Link_p_nom_extendable AND Link_active + where: not Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" dims: [scenario, snapshot, link] - where: not Link_p_nom_extendable AND Link_active + where: not Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom Generator_ext_p_lower: description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" @@ -1227,12 +1370,12 @@ constraints: Link_ext_p_lower: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, link] - where: Link_p_nom_extendable AND Link_active + where: Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p >= Link_p_min_pu * Link_p_nom_ext Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" dims: [scenario, snapshot, link] - where: Link_p_nom_extendable AND Link_active + where: Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p <= Link_p_max_pu * Link_p_nom_ext Link_ext_p_nom_lower: description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor" @@ -1506,6 +1649,196 @@ constraints: dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_shut_down <= Generator_n_mod + Link_com_p_lower: + description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * Link_status + Link_com_p_upper: + description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * Link_status + Link_com_transition_start_up: + description: "`Link-com-transition-start-up` — turning on is a start, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_start_up >= Link_status - Link_previous_status + Link_com_transition_shut_down: + description: "`Link-com-transition-shut-down` — turning off is a stop, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_shut_down >= Link_previous_status - Link_status + Link_com_up_time: + description: >- + `Link-com-up-time` — a link started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_up_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_start_up, along=snapshot, window=Link_min_up_time) <= Link_status + Link_com_down_time: + description: >- + `Link-com-down-time` — a link stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_down_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_shut_down, along=snapshot, window=Link_min_down_time) <= 1 - Link_status + Link_com_status_must_stay_up: + description: "`Link-com-status-min_up_time_must_stay_up` — a link still serving the up time it brought in stays on" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_up AND Link_active + expression: Link_status == 1 + Link_com_status_must_stay_down: + description: >- + `Link-com-status-min_down_time_must_stay_up` — a link still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_down AND Link_active + expression: Link_status == 0 + Link_p_ramp_limit_up_run_big_m: + description: >- + `Link-p-ramp_limit_up-run-bigM` — a committed extendable link + raises flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_limit_up * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_previous_status + Link_p_ramp_limit_up_start_big_m: + description: >- + `Link-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable link ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_limit_start_up * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_start_up + Link_p_ramp_limit_down_run_big_m: + description: >- + `Link-p-ramp_limit_down-run-bigM` — a committed extendable link + lowers flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_limit_down * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_status + Link_p_ramp_limit_down_shut_big_m: + description: >- + `Link-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable link ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_limit_shut_down * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_shut_down + Link_p_nom_modularity: + description: "`Link-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + expression: Link_p_nom_ext == Link_p_nom_mod * Link_n_mod + Link_com_ext_p_upper_cap: + description: >- + `Link-com-ext-p-upper-cap` — a committed extendable link flows + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_ext + Link_com_ext_p_upper_big_m: + description: "`Link-com-ext-p-upper-bigM` — off, a link flows nothing; on, the big M is no bound" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_big_m * Link_status + Link_com_ext_p_lower: + description: >- + `Link-com-ext-p-lower` — a committed extendable link flows at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: >- + Link_p >= + Link_p_min_pu * Link_p_nom_ext + + Link_big_m * Link_status - Link_big_m + Link_com_ext_p_lower_nonneg: + description: >- + `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, flow is also plainly non-negative, a row the big-M lower + cannot assert while the link is off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable + AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p >= 0 + Link_com_mod_p_lower: + description: >- + `Link-com-mod-p-lower` — a committed modular link flows at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * Link_status + Link_com_mod_p_upper: + description: >- + `Link-com-mod-p-upper` — a committed modular link flows at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * Link_status + Link_status_p_fixed_upper: + description: >- + `Link-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_status <= Link_modules_installed + Link_start_up_p_fixed_upper: + description: >- + `Link-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_start_up <= Link_modules_installed + Link_shut_down_p_fixed_upper: + description: >- + `Link-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_shut_down <= Link_modules_installed + Link_status_p_nom_variable_upper: + description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_status <= Link_n_mod + Link_start_up_p_nom_variable_upper: + description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_start_up <= Link_n_mod + Link_shut_down_p_nom_variable_upper: + description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_shut_down <= Link_n_mod Line_fix_s_lower: description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, line] @@ -1669,17 +2002,30 @@ constraints: expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Link_p_ramp_limit_up: description: >- - `Link-p-ramp_limit_up` — a link raises flow no faster than its limit of - the build. The translated term vacates the first snapshot, where a plain - optimize builds no row either + `Link-p-ramp_limit_up` — a link raises flow no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A link that came into the + horizon running brought an unknown flow, so it carries no row at the + first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] - where: Link_ramp_limit_up AND Link_active - expression: Link_p - shift(Link_p, along=snapshot, offset=1) <= Link_ramp_limit_up * Link_p_nom_effective + where: >- + Link_ramp_limit_up + AND NOT (Link_committable AND Link_p_nom_extendable) + AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + expression: Link_p - Link_previous_p <= Link_ramp_up_allowance Link_p_ramp_limit_down: - description: "`Link-p-ramp_limit_down` — a link lowers flow no faster than its limit of the build" + description: >- + `Link-p-ramp_limit_down` — a link lowers flow no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A link that came into the + horizon running brought an unknown flow, so it carries no row at the + first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] - where: Link_ramp_limit_down AND Link_active - expression: shift(Link_p, along=snapshot, offset=1) - Link_p <= Link_ramp_limit_down * Link_p_nom_effective + where: >- + Link_ramp_limit_down + AND NOT (Link_committable AND Link_p_nom_extendable) + AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + expression: Link_previous_p - Link_p <= Link_ramp_down_allowance Process_p_ramp_limit_up: description: >- `Process-p-ramp_limit_up` — a process raises internal power no faster than diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index c44f97ff..84716cc5 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -848,5 +848,56 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_25_committable_link": { + "columns": { + "Generator-p": 8, + "Link-n_mod": 1, + "Link-p": 24, + "Link-p_nom": 2, + "Link-shut_down": 20, + "Link-start_up": 20, + "Link-status": 20 + }, + "global_constraints": {}, + "objective": 14013.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-com-down-time": 6, + "Link-com-ext-p-lower": 4, + "Link-com-ext-p-lower-nonneg": 4, + "Link-com-ext-p-upper-bigM": 4, + "Link-com-ext-p-upper-cap": 4, + "Link-com-mod-p-lower": 8, + "Link-com-mod-p-upper": 8, + "Link-com-p-lower": 12, + "Link-com-p-upper": 12, + "Link-com-status-min_down_time_must_stay_up": 2, + "Link-com-status-min_up_time_must_stay_up": 2, + "Link-com-transition-shut-down": 20, + "Link-com-transition-start-up": 20, + "Link-com-up-time": 6, + "Link-ext-p_nom-lower": 2, + "Link-ext-p_nom-upper": 2, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Link-p-ramp_limit_down": 3, + "Link-p-ramp_limit_down-run-bigM": 4, + "Link-p-ramp_limit_down-shut-bigM": 4, + "Link-p-ramp_limit_up": 3, + "Link-p-ramp_limit_up-run-bigM": 4, + "Link-p-ramp_limit_up-start-bigM": 4, + "Link-p_nom_modularity": 1, + "Link-shut_down-p-fixed-upper": 16, + "Link-shut_down-p_nom-variable-upper": 4, + "Link-start_up-p-fixed-upper": 16, + "Link-start_up-p_nom-variable-upper": 4, + "Link-status-p-fixed-upper": 16, + "Link-status-p_nom-variable-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_25_committable_link.py b/examples/references/pypsa/rung_25_committable_link.py new file mode 100644 index 00000000..1004a07c --- /dev/null +++ b/examples/references/pypsa/rung_25_committable_link.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 25: committable links — a link held on by the up time it brought in, one held off by its down time, one kept on by its own up time, and committable builds that are extendable, modular or both.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus that only committable links serve.""" + n = spine.build() + n.add('Bus', 'east') + n.add( + 'Link', + 'hvdc', + bus0='north', + bus1='east', + committable=True, + p_nom=60, + p_min_pu=0.3, + marginal_cost=8, + min_up_time=3, + min_down_time=2, + up_time_before=1, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ramp_limit_start_up=0.6, + ramp_limit_shut_down=0.6, + start_up_cost=100, + shut_down_cost=50, + stand_by_cost=5, + ) + n.add( + 'Link', + 'cold_tie', + bus0='north', + bus1='east', + committable=True, + p_nom=40, + p_min_pu=0.2, + min_up_time=2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add( + 'Link', + 'ext_tie', + bus0='north', + bus1='east', + committable=True, + p_nom_extendable=True, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ) + n.add( + 'Link', + 'mod_tie', + bus0='south', + bus1='east', + committable=True, + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=40, + capital_cost=3, + p_min_pu=0.5, + ) + n.add( + 'Link', + 'mod_fix', + bus0='north', + bus1='east', + committable=True, + p_nom=20, + p_nom_mod=10, + p_min_pu=0.5, + marginal_cost=1, + ) + n.add('Load', 'east_load', bus='east', p_set=[20, 70, 60, 5]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index e46daf0d..7afb5e96 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -68,6 +68,29 @@ names: Link_output_cyclic_delay: '\mathrm{cyc}^{f}' Link_marginal_cost: '\mathrm{c}^{f}' Link_marginal_cost_quadratic: '\mathrm{c}^{f,(2)}' + Link_committable: '\mathrm{com}^{f}' + Link_ramp_limit_start_up: '\mathrm{ru}^{f,\mathrm{up}}' + Link_ramp_limit_shut_down: '\mathrm{rd}^{f,\mathrm{dn}}' + Link_status: "u^{f}" + Link_start_up: '\mathit{up}^{f}' + Link_shut_down: '\mathit{dn}^{f}' + Link_min_up_time: '\mathrm{UT}^{f}' + Link_min_down_time: '\mathrm{DT}^{f}' + Link_status_initial: '\mathrm{u}^{f,0}' + Link_previous_status: '\overleftarrow{u}^{f}' + Link_previous_p: '\overleftarrow{f}' + Link_ramp_up_allowance: '\Delta^{f,+}' + Link_ramp_down_allowance: '\Delta^{f,-}' + Link_must_stay_up: '\mathrm{hold}^{f}' + Link_must_stay_down: '\mathrm{rest}^{f}' + Link_start_up_cost: '\mathrm{c}^{f,\mathrm{up}}' + Link_shut_down_cost: '\mathrm{c}^{f,\mathrm{dn}}' + Link_stand_by_cost: '\mathrm{c}^{f,\mathrm{on}}' + Link_p_nom_mod: '\mathrm{f}^{\mathrm{mod}}' + Link_n_mod: "N^{f}" + Link_modules_installed: '\mathrm{N}^{f,\mathrm{fix}}' + Link_big_m: '\mathrm{M}^{f}' + Link_p_min_pu_nonneg: '\mathrm{nonneg}^{f}' Load_p_set: '\mathrm{load}' Generator_p_set: '\mathrm{p}^{\mathrm{set}}' Link_p_set: '\mathrm{f}^{\mathrm{set}}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index c5acc4ce..88e01053 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -169,6 +169,48 @@ constraints: Line_s_set: - snapshot - line + Link_com_down_time: + - snapshot + - link + Link_com_ext_p_lower: + - snapshot + - link + Link_com_ext_p_lower_nonneg: + - snapshot + - link + Link_com_ext_p_upper_big_m: + - snapshot + - link + Link_com_ext_p_upper_cap: + - snapshot + - link + Link_com_mod_p_lower: + - snapshot + - link + Link_com_mod_p_upper: + - snapshot + - link + Link_com_p_lower: + - snapshot + - link + Link_com_p_upper: + - snapshot + - link + Link_com_status_must_stay_down: + - snapshot + - link + Link_com_status_must_stay_up: + - snapshot + - link + Link_com_transition_shut_down: + - snapshot + - link + Link_com_transition_start_up: + - snapshot + - link + Link_com_up_time: + - snapshot + - link Link_ext_p_lower: - snapshot - link @@ -185,17 +227,49 @@ constraints: Link_fix_p_upper: - snapshot - link + Link_p_nom_modularity: + - link Link_p_nom_set: - link Link_p_ramp_limit_down: - snapshot - link + Link_p_ramp_limit_down_run_big_m: + - snapshot + - link + Link_p_ramp_limit_down_shut_big_m: + - snapshot + - link Link_p_ramp_limit_up: - snapshot - link + Link_p_ramp_limit_up_run_big_m: + - snapshot + - link + Link_p_ramp_limit_up_start_big_m: + - snapshot + - link Link_p_set: - snapshot - link + Link_shut_down_p_fixed_upper: + - snapshot + - link + Link_shut_down_p_nom_variable_upper: + - snapshot + - link + Link_start_up_p_fixed_upper: + - snapshot + - link + Link_start_up_p_nom_variable_upper: + - snapshot + - link + Link_status_p_fixed_upper: + - snapshot + - link + Link_status_p_nom_variable_upper: + - snapshot + - link Process_ext_p_lower: - snapshot - process @@ -327,6 +401,10 @@ expressions: - Generator_ramp_up_allowance - Link_output_arrival - Link_p_nom_effective + - Link_previous_p + - Link_previous_status + - Link_ramp_down_allowance + - Link_ramp_up_allowance - Process_output_arrival - Process_p_nom_effective - StorageUnit_charge_carried_in @@ -461,8 +539,12 @@ parameters: Line_volume_weight: - global_constraint - line + Link_big_m: + - link Link_capital_cost: - link + Link_committable: + - link Link_efficiency: - link_output Link_expansion_cost_weight: @@ -474,6 +556,18 @@ parameters: Link_marginal_cost_quadratic: - snapshot - link + Link_min_down_time: + - link + Link_min_up_time: + - link + Link_modules_installed: + - link + Link_must_stay_down: + - snapshot + - link + Link_must_stay_up: + - snapshot + - link Link_output_cyclic_delay: - link_output Link_output_delay: @@ -484,6 +578,8 @@ parameters: Link_p_min_pu: - snapshot - link + Link_p_min_pu_nonneg: + - link Link_p_nom: - link Link_p_nom_extendable: @@ -492,6 +588,8 @@ parameters: - link Link_p_nom_min: - link + Link_p_nom_mod: + - link Link_p_nom_set: - link Link_p_set: @@ -499,8 +597,21 @@ parameters: - link Link_ramp_limit_down: - link + Link_ramp_limit_shut_down: + - link + Link_ramp_limit_start_up: + - link Link_ramp_limit_up: - link + Link_shut_down_cost: + - link + Link_stand_by_cost: + - snapshot + - link + Link_start_up_cost: + - link + Link_status_initial: + - link Link_tech_capacity_weight: - global_constraint - link @@ -747,11 +858,22 @@ variables: - line Line_s_nom_ext: - line + Link_n_mod: + - link Link_p: - snapshot - link Link_p_nom_ext: - link + Link_shut_down: + - snapshot + - link + Link_start_up: + - snapshot + - link + Link_status: + - snapshot + - link Process_p: - snapshot - process diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 17af5401..452e9687 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -27,6 +27,7 @@ #: capacity is chosen once, before the future is known, so it spans no scenario SCENARIO_FREE_VARIABLES = { 'Generator_n_mod', + 'Link_n_mod', 'Generator_p_nom_ext', 'Link_p_nom_ext', 'StorageUnit_p_nom_ext', From 6da41d904eac3e4c6c62057907bc5d8629de8734 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 10:22:18 +0200 Subject: [PATCH 12/75] docs(pypsa): a committable process carries the generator's unit commitment Process gains the same unit commitment blocks as Link, over its internal power. rung_26 restates rung 25's links as processes drawing a quarter more than they deliver, objective 15956.125. --- docs/examples/pypsa.md | 776 +++++++++++++++++- examples/pypsa.yaml | 368 ++++++++- examples/references/pypsa/references.json | 52 ++ .../pypsa/rung_26_committable_process.py | 94 +++ examples/symbols/pypsa.yaml | 23 + tests/fixtures/pypsa_standard_shape.yaml | 122 +++ tests/test_pypsa_collapses.py | 1 + 7 files changed, 1405 insertions(+), 31 deletions(-) create mode 100644 examples/references/pypsa/rung_26_committable_process.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5e212b5d..10c50503 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -596,7 +596,7 @@ def build(): | PyPSA | status | note | | -------------------------------------------- | ------ | ------------------------------------------------------------- | -| [`{c}-status`, `-start_up`, `-shut_down`](#variable-domains) | done | Generator; Link in rung 25 | +| [`{c}-status`, `-start_up`, `-shut_down`](#variable-domains) | done | Generator; Link in rung 25, Process in rung 26 | | [`{c}-com-p-lower/upper`](#generator-com-p-lower) | done | | | [`{c}-*-p-fixed-upper`](#generator-status-p-fixed-upper) | done | status, start and stop each at most one, as explicit rows | | [`{c}-com-transition-start-up/shut-down`](#generator-com-transition-start-up) | done | the state carried into a snapshot is a cased quantity, so the first snapshot needs no block of its own | @@ -1543,7 +1543,7 @@ generator's and the link's, read over a converter. | --- | --- | --- | | [`Process-p`, `Process-p_nom`](#variable-domains) | done | internal power and capacity, as a link | | [`Process-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#process-fix-p-lower) | done | rungs 1 and 3, over a converter | -| [`Process-p-ramp_limit_*`](#process-p-ramp_limit_up) | done | rung 4, on a non-committable converter | +| [`Process-p-ramp_limit_*`](#process-p-ramp_limit_up) | done | rung 4, on a non-committable converter; committed in rung 26 | | [`Process-p_set`](#process-p_set) | done | a fixed internal power schedule | | [`Process-p_nom_set`](#process-p_nom_set) | done | a fixed built capacity | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | each port enters at its `rate` | @@ -2151,6 +2151,133 @@ def build(): +### Rung 26 — committable processes + +A committable process carries the same unit commitment over its internal power +`p`. The status gates `p`, not a port, so every port follows the status at its +own `rate`. This rung restates rung 25's links as processes that draw a quarter +more from the north than they deliver to the east. The same must-stay and up +time rules bind. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Process-status`, `-start_up`, `-shut_down`, `-n_mod`](#variable-domains) | done | as the link's, rung 25 | +| [`Process-com-p-*`, `-com-mod-p-*`, `-com-ext-p-*`](#process-com-p-lower) | done | a committable process leaves the `Process-fix-p-*` and `Process-ext-p-*` rows | +| [`Process-*-p-fixed-upper`, `-*-p_nom-variable-upper`](#process-status-p-fixed-upper) | done | | +| [`Process-com-transition-*`, `-com-up-time`, `-com-down-time`](#process-com-transition-start-up) | done | | +| [`Process-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#process-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | +| [`Process-p-ramp_limit_*`, `-*-bigM`](#process-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over internal power | +| [`Process-p_nom_modularity`](#process-p_nom_modularity) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `15956.125`, 235 rows. + +
+The network, as PyPSA code + +`rung_26_committable_process.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 26: committable processes — rung 25's committable links restated as processes that draw a quarter more than they deliver.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus that only committable processes serve.""" + n = spine.build() + n.add('Bus', 'east') + n.add( + 'Process', + 'warm_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=60, + p_min_pu=0.3, + marginal_cost=8, + min_up_time=3, + min_down_time=2, + up_time_before=1, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ramp_limit_start_up=0.6, + ramp_limit_shut_down=0.6, + start_up_cost=100, + shut_down_cost=50, + stand_by_cost=5, + ) + n.add( + 'Process', + 'cold_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=40, + p_min_pu=0.2, + min_up_time=2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add( + 'Process', + 'ext_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom_extendable=True, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ) + n.add( + 'Process', + 'mod_conv', + bus0='south', + bus1='east', + rate0=-1.25, + committable=True, + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=40, + capital_cost=3, + p_min_pu=0.5, + ) + n.add( + 'Process', + 'mod_fix', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=20, + p_nom_mod=10, + p_min_pu=0.5, + marginal_cost=1, + ) + n.add('Load', 'east_load', bus='east', p_set=[20, 70, 60, 5]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2268,6 +2395,21 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\mathcal{J}`$ — most nominal power an extendable process may be built at | | $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\mathcal{J}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | | $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | +| $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$ — whether internal power is gated by an on/off status decision | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | +| $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\mathcal{J}`$ — least snapshots a process stays on once started | +| $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\mathcal{J}`$ — least snapshots a process stays off once stopped | +| $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\mathcal{J}`$ — cost of one start | +| $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\mathcal{J}`$ — cost of one stop | +| $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\mathcal{J}`$ — a bound safely above any feasible internal power — the build cap at full availability, data prep | +| $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | @@ -2416,6 +2558,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-status` — how much of a committable link is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is | | $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is | +| $`N^{z}`$ | `Process_n_mod` over $`\mathcal{J}`$ — `Process-n_mod` — how many modules of an extendable modular build | +| $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-status` — how much of a committable process is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}^{z}`$ | `Process_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is | +| $`\mathit{dn}^{z}`$ | `Process_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | @@ -2447,6 +2593,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{f,+}`$ | `Link_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may raise flow between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — nothing at the start of the horizon, which is why a process that came in running carries no ramp row there | +| $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | @@ -2753,12 +2903,12 @@ F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{l} \qquad \forall\, l \in \mathcal{L} \,: Process_fix_p_lower: description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" dims: [scenario, snapshot, process] - where: not Process_p_nom_extendable AND Process_active + where: not Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p >= Process_p_min_pu * Process_p_nom ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-fix-p-upper` @@ -2769,12 +2919,12 @@ z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} Process_fix_p_upper: description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" dims: [scenario, snapshot, process] - where: not Process_p_nom_extendable AND Process_active + where: not Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p <= Process_p_max_pu * Process_p_nom ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-lower` @@ -2785,12 +2935,12 @@ z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} Process_ext_p_lower: description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, process] - where: Process_p_nom_extendable AND Process_active + where: Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p >= Process_p_min_pu * Process_p_nom_ext ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-upper` @@ -2801,12 +2951,12 @@ z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \i Process_ext_p_upper: description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" dims: [scenario, snapshot, process] - where: Process_p_nom_extendable AND Process_active + where: Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p <= Process_p_max_pu * Process_p_nom_ext ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p_nom-lower` @@ -3885,6 +4035,471 @@ Link_shut_down_p_nom_variable_upper: \mathit{dn}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` +### `Process-com-p-lower` + +`Process_com_p_lower` + +```yaml +Process_com_p_lower: + description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * Process_status +``` + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-p-upper` + +`Process_com_p_upper` + +```yaml +Process_com_p_upper: + description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * Process_status +``` + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-transition-start-up` + +`Process_com_transition_start_up` + +```yaml +Process_com_transition_start_up: + description: "`Process-com-transition-start-up` — turning on is a start, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_start_up >= Process_status - Process_previous_status +``` + +```math +\mathit{up}^{z}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-transition-shut-down` + +`Process_com_transition_shut_down` + +```yaml +Process_com_transition_shut_down: + description: "`Process-com-transition-shut-down` — turning off is a stop, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_shut_down >= Process_previous_status - Process_status +``` + +```math +\mathit{dn}^{z}_{\xi,t,j} \ge \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-up-time` + +`Process_com_up_time` + +```yaml +Process_com_up_time: + description: >- + `Process-com-up-time` — a process started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_up_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_start_up, along=snapshot, window=Process_min_up_time) <= Process_status +``` + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{z}} \mathit{up}^{z}_{\xi,t',j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{UT}^{z}_{j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-down-time` + +`Process_com_down_time` + +```yaml +Process_com_down_time: + description: >- + `Process-com-down-time` — a process stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_down_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_shut_down, along=snapshot, window=Process_min_down_time) <= 1 - Process_status +``` + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{z}} \mathit{dn}^{z}_{\xi,t',j} \le 1 - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{DT}^{z}_{j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-status-min_up_time_must_stay_up` + +`Process_com_status_must_stay_up` + +```yaml +Process_com_status_must_stay_up: + description: "`Process-com-status-min_up_time_must_stay_up` — a process still serving the up time it brought in stays on" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_up AND Process_active + expression: Process_status == 1 +``` + +```math +u^{z}_{\xi,t,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{hold}^{z}_{t,j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-status-min_down_time_must_stay_up` + +`Process_com_status_must_stay_down` + +```yaml +Process_com_status_must_stay_down: + description: >- + `Process-com-status-min_down_time_must_stay_up` — a process still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_down AND Process_active + expression: Process_status == 0 +``` + +```math +u^{z}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{rest}^{z}_{t,j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p-ramp_limit_up-run-bigM` + +`Process_p_ramp_limit_up_run_big_m` + +```yaml +Process_p_ramp_limit_up_run_big_m: + description: >- + `Process-p-ramp_limit_up-run-bigM` — a committed extendable process + raises internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_limit_up * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_previous_status +``` + +```math +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \mathrm{ru}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p-ramp_limit_up-start-bigM` + +`Process_p_ramp_limit_up_start_big_m` + +```yaml +Process_p_ramp_limit_up_start_big_m: + description: >- + `Process-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable process ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_limit_start_up * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_start_up +``` + +```math +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \mathrm{ru}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p-ramp_limit_down-run-bigM` + +`Process_p_ramp_limit_down_run_big_m` + +```yaml +Process_p_ramp_limit_down_run_big_m: + description: >- + `Process-p-ramp_limit_down-run-bigM` — a committed extendable process + lowers internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_limit_down * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_status +``` + +```math +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \mathrm{rd}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p-ramp_limit_down-shut-bigM` + +`Process_p_ramp_limit_down_shut_big_m` + +```yaml +Process_p_ramp_limit_down_shut_big_m: + description: >- + `Process-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable process ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_limit_shut_down * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_shut_down +``` + +```math +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \mathrm{rd}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-p_nom_modularity` + +`Process_p_nom_modularity` + +```yaml +Process_p_nom_modularity: + description: "`Process-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + expression: Process_p_nom_ext == Process_p_nom_mod * Process_n_mod +``` + +```math +Z_{j} = \mathrm{z}^{\mathrm{mod}}_{j} \cdot N^{z}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + +### `Process-com-ext-p-upper-cap` + +`Process_com_ext_p_upper_cap` + +```yaml +Process_com_ext_p_upper_cap: + description: >- + `Process-com-ext-p-upper-cap` — a committed extendable process runs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_ext +``` + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-ext-p-upper-bigM` + +`Process_com_ext_p_upper_big_m` + +```yaml +Process_com_ext_p_upper_big_m: + description: "`Process-com-ext-p-upper-bigM` — off, a process does not run; on, the big M is no bound" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_big_m * Process_status +``` + +```math +z_{\xi,t,j} \le \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-ext-p-lower` + +`Process_com_ext_p_lower` + +```yaml +Process_com_ext_p_lower: + description: >- + `Process-com-ext-p-lower` — a committed extendable process runs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: >- + Process_p >= + Process_p_min_pu * Process_p_nom_ext + + Process_big_m * Process_status - Process_big_m +``` + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} + \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-ext-p-lower-nonneg` + +`Process_com_ext_p_lower_nonneg` + +```yaml +Process_com_ext_p_lower_nonneg: + description: >- + `Process-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, internal power is also plainly non-negative, a row the big-M lower + cannot assert while the process is off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable + AND Process_p_min_pu_nonneg AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p >= 0 +``` + +```math +z_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{nonneg}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-mod-p-lower` + +`Process_com_mod_p_lower` + +```yaml +Process_com_mod_p_lower: + description: >- + `Process-com-mod-p-lower` — a committed modular process runs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * Process_status +``` + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-com-mod-p-upper` + +`Process_com_mod_p_upper` + +```yaml +Process_com_mod_p_upper: + description: >- + `Process-com-mod-p-upper` — a committed modular process runs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * Process_status +``` + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-status-p-fixed-upper` + +`Process_status_p_fixed_upper` + +```yaml +Process_status_p_fixed_upper: + description: >- + `Process-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_status <= Process_modules_installed +``` + +```math +u^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-start_up-p-fixed-upper` + +`Process_start_up_p_fixed_upper` + +```yaml +Process_start_up_p_fixed_upper: + description: >- + `Process-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_start_up <= Process_modules_installed +``` + +```math +\mathit{up}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-shut_down-p-fixed-upper` + +`Process_shut_down_p_fixed_upper` + +```yaml +Process_shut_down_p_fixed_upper: + description: >- + `Process-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_shut_down <= Process_modules_installed +``` + +```math +\mathit{dn}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-status-p_nom-variable-upper` + +`Process_status_p_nom_variable_upper` + +```yaml +Process_status_p_nom_variable_upper: + description: "`Process-status-p_nom-variable-upper` — a modular process is on only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_status <= Process_n_mod +``` + +```math +u^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-start_up-p_nom-variable-upper` + +`Process_start_up_p_nom_variable_upper` + +```yaml +Process_start_up_p_nom_variable_upper: + description: "`Process-start_up-p_nom-variable-upper` — a modular process starts only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_start_up <= Process_n_mod +``` + +```math +\mathit{up}^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-shut_down-p_nom-variable-upper` + +`Process_shut_down_p_nom_variable_upper` + +```yaml +Process_shut_down_p_nom_variable_upper: + description: "`Process-shut_down-p_nom-variable-upper` — a modular process stops only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_shut_down <= Process_n_mod +``` + +```math +\mathit{dn}^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + ### `Line-fix-s-lower` `Line_fix_s_lower` @@ -4377,15 +4992,20 @@ Link_p_ramp_limit_down: Process_p_ramp_limit_up: description: >- `Process-p-ramp_limit_up` — a process raises internal power no faster than - its limit of the build. The translated term vacates the first snapshot, - where a plain optimize builds no row either + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A process that came into the + horizon running brought an unknown internal power, so it carries no row at the + first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] - where: Process_ramp_limit_up AND Process_active - expression: Process_p - shift(Process_p, along=snapshot, offset=1) <= Process_ramp_limit_up * Process_p_nom_effective + where: >- + Process_ramp_limit_up + AND NOT (Process_committable AND Process_p_nom_extendable) + AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + expression: Process_p - Process_previous_p <= Process_ramp_up_allowance ``` ```math -z_{\xi,t,j} - z_{\xi,t - 1,j} \le \mathrm{ru}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -4394,14 +5014,22 @@ z_{\xi,t,j} - z_{\xi,t - 1,j} \le \mathrm{ru}^{z}_{j} \cdot \widetilde{\mathrm{z ```yaml Process_p_ramp_limit_down: - description: "`Process-p-ramp_limit_down` — a process lowers internal power no faster than its limit of the build" + description: >- + `Process-p-ramp_limit_down` — a process lowers internal power no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A process that came into the + horizon running brought an unknown internal power, so it carries no row at the + first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] - where: Process_ramp_limit_down AND Process_active - expression: shift(Process_p, along=snapshot, offset=1) - Process_p <= Process_ramp_limit_down * Process_p_nom_effective + where: >- + Process_ramp_limit_down + AND NOT (Process_committable AND Process_p_nom_extendable) + AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + expression: Process_previous_p - Process_p <= Process_ramp_down_allowance ``` ```math -z_{\xi,t - 1,j} - z_{\xi,t,j} \le \mathrm{rd}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -5340,6 +5968,87 @@ Process_p_nom_effective: \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} = \begin{cases} Z_{j} & \text{if } \mathrm{ext}^{z}_{j} \\ \mathrm{z}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} ``` +### `Process_previous_status` + +```yaml +Process_previous_status: + description: >- + the commitment state a process carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial } + otherwise: shift(Process_status, along=snapshot, offset=1) +``` + +```math +\overleftarrow{u}^{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{j} & \text{if } \mathrm{pos}(t) = 0 \\ u^{z}_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +### `Process_previous_p` + +```yaml +Process_previous_p: + description: >- + the internal power a process carries into a snapshot — nothing at the start of + the horizon, which is why a process that came in running carries no ramp row + there + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: 0 } + otherwise: shift(Process_p, along=snapshot, offset=1) +``` + +```math +\overleftarrow{z}_{\xi,t,j} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ z_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +### `Process_ramp_up_allowance` + +```yaml +Process_ramp_up_allowance: + description: >- + how far a process may raise internal power between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_limit_up * Process_p_nom * Process_previous_status + + Process_ramp_limit_start_up * Process_p_nom + * (Process_status - Process_previous_status) + otherwise: Process_ramp_limit_up * Process_p_nom_effective +``` + +```math +\Delta^{z,+}_{\xi,t,j} = \begin{cases} \mathrm{ru}^{z}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \mathrm{ru}^{z,\mathrm{up}}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \mathrm{ru}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +### `Process_ramp_down_allowance` + +```yaml +Process_ramp_down_allowance: + description: >- + how far a process may lower internal power between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_limit_down * Process_p_nom * Process_status + + Process_ramp_limit_shut_down * Process_p_nom + * (Process_previous_status - Process_status) + otherwise: Process_ramp_limit_down * Process_p_nom_effective +``` + +```math +\Delta^{z,-}_{\xi,t,j} = \begin{cases} \mathrm{rd}^{z}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} + \mathrm{rd}^{z,\mathrm{dn}}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \mathrm{rd}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + ### `StorageUnit_charge_carried_in` ```yaml @@ -5537,10 +6246,13 @@ scenario_opex: + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` ### `Carrier_additions` @@ -5670,6 +6382,30 @@ u^{f}_{\xi,t,l} \ge 0, u^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \ \mathit{dn}^{f}_{\xi,t,l} \ge 0, \mathit{dn}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` +**`Process_n_mod`** + +```math +N^{z}_{j} \ge 0, N^{z}_{j} \in \mathbb{Z} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + +**`Process_status`** + +```math +u^{z}_{\xi,t,j} \ge 0, u^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_start_up`** + +```math +\mathit{up}^{z}_{\xi,t,j} \ge 0, \mathit{up}^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_shut_down`** + +```math +\mathit{dn}^{z}_{\xi,t,j} \ge 0, \mathit{dn}^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + **`Line_s`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 3e9846a4..5f8490f3 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -324,6 +324,70 @@ parameters: Process_p_nom_set: description: a given nominal power for an extendable process; one without a value has no row here dims: [process] + Process_committable: + description: whether internal power is gated by an on/off status decision + dims: [process] + dtype: bool + Process_ramp_limit_start_up: + description: most internal power in the snapshot a process starts, per unit of nominal power + dims: [process] + Process_ramp_limit_shut_down: + description: most internal power in the snapshot before a process stops, per unit of nominal power + dims: [process] + Process_min_up_time: + description: least snapshots a process stays on once started + dims: [process] + dtype: int + Process_min_down_time: + description: least snapshots a process stays off once stopped + dims: [process] + dtype: int + Process_status_initial: + description: one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [process] + dtype: int + Process_must_stay_up: + description: >- + true while the up time a process brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [snapshot, process] + dtype: bool + Process_must_stay_down: + description: >- + true while the down time a process brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [snapshot, process] + dtype: bool + Process_start_up_cost: + description: cost of one start + dims: [process] + Process_shut_down_cost: + description: cost of one stop + dims: [process] + Process_stand_by_cost: + description: cost of one snapshot spent on + dims: [snapshot, process] + Process_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [process] + Process_modules_installed: + description: >- + how many whole modules a committable build has in place: `Process_p_nom + / Process_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [process] + Process_big_m: + description: a bound safely above any feasible internal power — the build cap at full availability, data prep + dims: [process] + Process_p_min_pu_nonneg: + description: >- + true where none of the process's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep + dims: [process] + dtype: bool Load_p_set: description: demand dims: [scenario, snapshot, load] @@ -975,6 +1039,37 @@ variables: domain: integer bounds: lower: 0 + Process_n_mod: + description: "`Process-n_mod` — how many modules of an extendable modular build" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Process_status: + description: >- + `Process-status` — how much of a committable process is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_start_up: + description: "`Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_shut_down: + description: "`Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 Line_s: description: >- `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a @@ -1183,6 +1278,51 @@ expressions: cases: extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } otherwise: Process_p_nom + Process_previous_status: + description: >- + the commitment state a process carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial } + otherwise: shift(Process_status, along=snapshot, offset=1) + Process_previous_p: + description: >- + the internal power a process carries into a snapshot — nothing at the start of + the horizon, which is why a process that came in running carries no ramp row + there + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: 0 } + otherwise: shift(Process_p, along=snapshot, offset=1) + Process_ramp_up_allowance: + description: >- + how far a process may raise internal power between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_limit_up * Process_p_nom * Process_previous_status + + Process_ramp_limit_start_up * Process_p_nom + * (Process_status - Process_previous_status) + otherwise: Process_ramp_limit_up * Process_p_nom_effective + Process_ramp_down_allowance: + description: >- + how far a process may lower internal power between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_limit_down * Process_p_nom * Process_status + + Process_ramp_limit_shut_down * Process_p_nom + * (Process_previous_status - Process_status) + otherwise: Process_ramp_limit_down * Process_p_nom_effective StorageUnit_charge_carried_in: description: >- the charge a unit opens a snapshot with — its last snapshot's less @@ -1296,6 +1436,9 @@ expressions: + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) Carrier_additions: description: >- what a carrier adds in a period — every extendable component of that @@ -1395,22 +1538,22 @@ constraints: Process_fix_p_lower: description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" dims: [scenario, snapshot, process] - where: not Process_p_nom_extendable AND Process_active + where: not Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p >= Process_p_min_pu * Process_p_nom Process_fix_p_upper: description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" dims: [scenario, snapshot, process] - where: not Process_p_nom_extendable AND Process_active + where: not Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p <= Process_p_max_pu * Process_p_nom Process_ext_p_lower: description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, process] - where: Process_p_nom_extendable AND Process_active + where: Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p >= Process_p_min_pu * Process_p_nom_ext Process_ext_p_upper: description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" dims: [scenario, snapshot, process] - where: Process_p_nom_extendable AND Process_active + where: Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p <= Process_p_max_pu * Process_p_nom_ext Process_ext_p_nom_lower: description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" @@ -1839,6 +1982,196 @@ constraints: dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active expression: Link_shut_down <= Link_n_mod + Process_com_p_lower: + description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * Process_status + Process_com_p_upper: + description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * Process_status + Process_com_transition_start_up: + description: "`Process-com-transition-start-up` — turning on is a start, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_start_up >= Process_status - Process_previous_status + Process_com_transition_shut_down: + description: "`Process-com-transition-shut-down` — turning off is a stop, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_shut_down >= Process_previous_status - Process_status + Process_com_up_time: + description: >- + `Process-com-up-time` — a process started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_up_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_start_up, along=snapshot, window=Process_min_up_time) <= Process_status + Process_com_down_time: + description: >- + `Process-com-down-time` — a process stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_down_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_shut_down, along=snapshot, window=Process_min_down_time) <= 1 - Process_status + Process_com_status_must_stay_up: + description: "`Process-com-status-min_up_time_must_stay_up` — a process still serving the up time it brought in stays on" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_up AND Process_active + expression: Process_status == 1 + Process_com_status_must_stay_down: + description: >- + `Process-com-status-min_down_time_must_stay_up` — a process still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_down AND Process_active + expression: Process_status == 0 + Process_p_ramp_limit_up_run_big_m: + description: >- + `Process-p-ramp_limit_up-run-bigM` — a committed extendable process + raises internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_limit_up * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_previous_status + Process_p_ramp_limit_up_start_big_m: + description: >- + `Process-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable process ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_limit_start_up * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_start_up + Process_p_ramp_limit_down_run_big_m: + description: >- + `Process-p-ramp_limit_down-run-bigM` — a committed extendable process + lowers internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_limit_down * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_status + Process_p_ramp_limit_down_shut_big_m: + description: >- + `Process-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable process ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_limit_shut_down * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_shut_down + Process_p_nom_modularity: + description: "`Process-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + expression: Process_p_nom_ext == Process_p_nom_mod * Process_n_mod + Process_com_ext_p_upper_cap: + description: >- + `Process-com-ext-p-upper-cap` — a committed extendable process runs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + Process_com_ext_p_upper_big_m: + description: "`Process-com-ext-p-upper-bigM` — off, a process does not run; on, the big M is no bound" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_big_m * Process_status + Process_com_ext_p_lower: + description: >- + `Process-com-ext-p-lower` — a committed extendable process runs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: >- + Process_p >= + Process_p_min_pu * Process_p_nom_ext + + Process_big_m * Process_status - Process_big_m + Process_com_ext_p_lower_nonneg: + description: >- + `Process-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, internal power is also plainly non-negative, a row the big-M lower + cannot assert while the process is off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable + AND Process_p_min_pu_nonneg AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p >= 0 + Process_com_mod_p_lower: + description: >- + `Process-com-mod-p-lower` — a committed modular process runs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * Process_status + Process_com_mod_p_upper: + description: >- + `Process-com-mod-p-upper` — a committed modular process runs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * Process_status + Process_status_p_fixed_upper: + description: >- + `Process-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_status <= Process_modules_installed + Process_start_up_p_fixed_upper: + description: >- + `Process-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_start_up <= Process_modules_installed + Process_shut_down_p_fixed_upper: + description: >- + `Process-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_shut_down <= Process_modules_installed + Process_status_p_nom_variable_upper: + description: "`Process-status-p_nom-variable-upper` — a modular process is on only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_status <= Process_n_mod + Process_start_up_p_nom_variable_upper: + description: "`Process-start_up-p_nom-variable-upper` — a modular process starts only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_start_up <= Process_n_mod + Process_shut_down_p_nom_variable_upper: + description: "`Process-shut_down-p_nom-variable-upper` — a modular process stops only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_shut_down <= Process_n_mod Line_fix_s_lower: description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, line] @@ -2029,16 +2362,29 @@ constraints: Process_p_ramp_limit_up: description: >- `Process-p-ramp_limit_up` — a process raises internal power no faster than - its limit of the build. The translated term vacates the first snapshot, - where a plain optimize builds no row either + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A process that came into the + horizon running brought an unknown internal power, so it carries no row at the + first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] - where: Process_ramp_limit_up AND Process_active - expression: Process_p - shift(Process_p, along=snapshot, offset=1) <= Process_ramp_limit_up * Process_p_nom_effective + where: >- + Process_ramp_limit_up + AND NOT (Process_committable AND Process_p_nom_extendable) + AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + expression: Process_p - Process_previous_p <= Process_ramp_up_allowance Process_p_ramp_limit_down: - description: "`Process-p-ramp_limit_down` — a process lowers internal power no faster than its limit of the build" + description: >- + `Process-p-ramp_limit_down` — a process lowers internal power no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A process that came into the + horizon running brought an unknown internal power, so it carries no row at the + first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] - where: Process_ramp_limit_down AND Process_active - expression: shift(Process_p, along=snapshot, offset=1) - Process_p <= Process_ramp_limit_down * Process_p_nom_effective + where: >- + Process_ramp_limit_down + AND NOT (Process_committable AND Process_p_nom_extendable) + AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + expression: Process_previous_p - Process_p <= Process_ramp_down_allowance StorageUnit_ext_p_dispatch_lower: description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" dims: [scenario, snapshot, storage_unit] diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 84716cc5..e464a0c0 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -899,5 +899,57 @@ "Link-status-p-fixed-upper": 16, "Link-status-p_nom-variable-upper": 4 } + }, + "rung_26_committable_process": { + "columns": { + "Generator-p": 8, + "Link-p": 4, + "Process-n_mod": 1, + "Process-p": 20, + "Process-p_nom": 2, + "Process-shut_down": 20, + "Process-start_up": 20, + "Process-status": 20 + }, + "global_constraints": {}, + "objective": 15956.125, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Process-com-down-time": 6, + "Process-com-ext-p-lower": 4, + "Process-com-ext-p-lower-nonneg": 4, + "Process-com-ext-p-upper-bigM": 4, + "Process-com-ext-p-upper-cap": 4, + "Process-com-mod-p-lower": 8, + "Process-com-mod-p-upper": 8, + "Process-com-p-lower": 12, + "Process-com-p-upper": 12, + "Process-com-status-min_down_time_must_stay_up": 2, + "Process-com-status-min_up_time_must_stay_up": 2, + "Process-com-transition-shut-down": 20, + "Process-com-transition-start-up": 20, + "Process-com-up-time": 6, + "Process-ext-p_nom-lower": 2, + "Process-ext-p_nom-upper": 2, + "Process-p-ramp_limit_down": 3, + "Process-p-ramp_limit_down-run-bigM": 4, + "Process-p-ramp_limit_down-shut-bigM": 4, + "Process-p-ramp_limit_up": 3, + "Process-p-ramp_limit_up-run-bigM": 4, + "Process-p-ramp_limit_up-start-bigM": 4, + "Process-p_nom_modularity": 1, + "Process-shut_down-p-fixed-upper": 16, + "Process-shut_down-p_nom-variable-upper": 4, + "Process-start_up-p-fixed-upper": 16, + "Process-start_up-p_nom-variable-upper": 4, + "Process-status-p-fixed-upper": 16, + "Process-status-p_nom-variable-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_26_committable_process.py b/examples/references/pypsa/rung_26_committable_process.py new file mode 100644 index 00000000..bfb9cdf9 --- /dev/null +++ b/examples/references/pypsa/rung_26_committable_process.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 26: committable processes — rung 25's committable links restated as processes that draw a quarter more than they deliver.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus that only committable processes serve.""" + n = spine.build() + n.add('Bus', 'east') + n.add( + 'Process', + 'warm_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=60, + p_min_pu=0.3, + marginal_cost=8, + min_up_time=3, + min_down_time=2, + up_time_before=1, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ramp_limit_start_up=0.6, + ramp_limit_shut_down=0.6, + start_up_cost=100, + shut_down_cost=50, + stand_by_cost=5, + ) + n.add( + 'Process', + 'cold_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=40, + p_min_pu=0.2, + min_up_time=2, + min_down_time=3, + up_time_before=0, + down_time_before=1, + start_up_cost=20, + ) + n.add( + 'Process', + 'ext_conv', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom_extendable=True, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + ) + n.add( + 'Process', + 'mod_conv', + bus0='south', + bus1='east', + rate0=-1.25, + committable=True, + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=40, + capital_cost=3, + p_min_pu=0.5, + ) + n.add( + 'Process', + 'mod_fix', + bus0='north', + bus1='east', + rate0=-1.25, + committable=True, + p_nom=20, + p_nom_mod=10, + p_min_pu=0.5, + marginal_cost=1, + ) + n.add('Load', 'east_load', bus='east', p_set=[20, 70, 60, 5]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 7afb5e96..5705fda4 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -130,6 +130,29 @@ names: Process_p_nom_ext: "Z" Process_p_nom_effective: '\widetilde{\mathrm{z}}^{\mathrm{nom}}' Process_output_arrival: '\overrightarrow{z}' + Process_committable: '\mathrm{com}^{z}' + Process_ramp_limit_start_up: '\mathrm{ru}^{z,\mathrm{up}}' + Process_ramp_limit_shut_down: '\mathrm{rd}^{z,\mathrm{dn}}' + Process_status: "u^{z}" + Process_start_up: '\mathit{up}^{z}' + Process_shut_down: '\mathit{dn}^{z}' + Process_min_up_time: '\mathrm{UT}^{z}' + Process_min_down_time: '\mathrm{DT}^{z}' + Process_status_initial: '\mathrm{u}^{z,0}' + Process_previous_status: '\overleftarrow{u}^{z}' + Process_previous_p: '\overleftarrow{z}' + Process_ramp_up_allowance: '\Delta^{z,+}' + Process_ramp_down_allowance: '\Delta^{z,-}' + Process_must_stay_up: '\mathrm{hold}^{z}' + Process_must_stay_down: '\mathrm{rest}^{z}' + Process_start_up_cost: '\mathrm{c}^{z,\mathrm{up}}' + Process_shut_down_cost: '\mathrm{c}^{z,\mathrm{dn}}' + Process_stand_by_cost: '\mathrm{c}^{z,\mathrm{on}}' + Process_p_nom_mod: '\mathrm{z}^{\mathrm{mod}}' + Process_n_mod: "N^{z}" + Process_modules_installed: '\mathrm{N}^{z,\mathrm{fix}}' + Process_big_m: '\mathrm{M}^{z}' + Process_p_min_pu_nonneg: '\mathrm{nonneg}^{z}' StorageUnit_p_nom_ext: "H" StorageUnit_p_nom_min: '\underline{\mathrm{h}}^{\mathrm{nom}}' StorageUnit_p_nom_max: '\overline{\mathrm{h}}^{\mathrm{nom}}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index 88e01053..b34c500e 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -270,6 +270,48 @@ constraints: Link_status_p_nom_variable_upper: - snapshot - link + Process_com_down_time: + - snapshot + - process + Process_com_ext_p_lower: + - snapshot + - process + Process_com_ext_p_lower_nonneg: + - snapshot + - process + Process_com_ext_p_upper_big_m: + - snapshot + - process + Process_com_ext_p_upper_cap: + - snapshot + - process + Process_com_mod_p_lower: + - snapshot + - process + Process_com_mod_p_upper: + - snapshot + - process + Process_com_p_lower: + - snapshot + - process + Process_com_p_upper: + - snapshot + - process + Process_com_status_must_stay_down: + - snapshot + - process + Process_com_status_must_stay_up: + - snapshot + - process + Process_com_transition_shut_down: + - snapshot + - process + Process_com_transition_start_up: + - snapshot + - process + Process_com_up_time: + - snapshot + - process Process_ext_p_lower: - snapshot - process @@ -286,17 +328,49 @@ constraints: Process_fix_p_upper: - snapshot - process + Process_p_nom_modularity: + - process Process_p_nom_set: - process Process_p_ramp_limit_down: - snapshot - process + Process_p_ramp_limit_down_run_big_m: + - snapshot + - process + Process_p_ramp_limit_down_shut_big_m: + - snapshot + - process Process_p_ramp_limit_up: - snapshot - process + Process_p_ramp_limit_up_run_big_m: + - snapshot + - process + Process_p_ramp_limit_up_start_big_m: + - snapshot + - process Process_p_set: - snapshot - process + Process_shut_down_p_fixed_upper: + - snapshot + - process + Process_shut_down_p_nom_variable_upper: + - snapshot + - process + Process_start_up_p_fixed_upper: + - snapshot + - process + Process_start_up_p_nom_variable_upper: + - snapshot + - process + Process_status_p_fixed_upper: + - snapshot + - process + Process_status_p_nom_variable_upper: + - snapshot + - process StorageUnit_energy_balance: - snapshot - storage_unit @@ -407,6 +481,10 @@ expressions: - Link_ramp_up_allowance - Process_output_arrival - Process_p_nom_effective + - Process_previous_p + - Process_previous_status + - Process_ramp_down_allowance + - Process_ramp_up_allowance - StorageUnit_charge_carried_in - Store_energy_carried_in - operational_limit @@ -624,13 +702,29 @@ parameters: Process_active: - snapshot - process + Process_big_m: + - process Process_capital_cost: - process Process_capital_weight: - process + Process_committable: + - process Process_marginal_cost: - snapshot - process + Process_min_down_time: + - process + Process_min_up_time: + - process + Process_modules_installed: + - process + Process_must_stay_down: + - snapshot + - process + Process_must_stay_up: + - snapshot + - process Process_output_cyclic_delay: - process_output Process_output_delay: @@ -641,6 +735,8 @@ parameters: Process_p_min_pu: - snapshot - process + Process_p_min_pu_nonneg: + - process Process_p_nom: - process Process_p_nom_extendable: @@ -649,6 +745,8 @@ parameters: - process Process_p_nom_min: - process + Process_p_nom_mod: + - process Process_p_nom_set: - process Process_p_set: @@ -656,10 +754,23 @@ parameters: - process Process_ramp_limit_down: - process + Process_ramp_limit_shut_down: + - process + Process_ramp_limit_start_up: + - process Process_ramp_limit_up: - process Process_rate: - process_output + Process_shut_down_cost: + - process + Process_stand_by_cost: + - snapshot + - process + Process_start_up_cost: + - process + Process_status_initial: + - process Process_tech_capacity_weight: - global_constraint - process @@ -874,11 +985,22 @@ variables: Link_status: - snapshot - link + Process_n_mod: + - process Process_p: - snapshot - process Process_p_nom_ext: - process + Process_shut_down: + - snapshot + - process + Process_start_up: + - snapshot + - process + Process_status: + - snapshot + - process StorageUnit_p_dispatch: - snapshot - storage_unit diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 452e9687..344a8561 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -28,6 +28,7 @@ SCENARIO_FREE_VARIABLES = { 'Generator_n_mod', 'Link_n_mod', + 'Process_n_mod', 'Generator_p_nom_ext', 'Link_p_nom_ext', 'StorageUnit_p_nom_ext', From 4f67f5359be8f7d9a97dcfe83edc6a8121b883c9 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 10:43:38 +0200 Subject: [PATCH 13/75] docs(pypsa): a ramp row follows pypsa for a modular committed build and a start-up ramp alone A committable extendable modular unit takes the ordinary ramp rows against one module, not the big-M rows. A start-up or shut-down ramp alone builds the row, and a missing limit reads as the full build. rung_27 (45469.5) and rung_28 (83283.0) record both for Generator, Link and Process. --- docs/examples/pypsa.md | 561 +++++++++++++++--- examples/pypsa.yaml | 240 ++++++-- examples/references/pypsa/references.json | 123 ++++ .../references/pypsa/rung_27_modular_ramp.py | 34 ++ .../references/pypsa/rung_28_start_up_ramp.py | 29 + examples/symbols/pypsa.yaml | 15 + 6 files changed, 868 insertions(+), 134 deletions(-) create mode 100644 examples/references/pypsa/rung_27_modular_ramp.py create mode 100644 examples/references/pypsa/rung_28_start_up_ramp.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 10c50503..f22c2b8f 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -374,7 +374,7 @@ def build(): | PyPSA | status | note | | ------------------------------ | ------ | ---------------------------------------------------------- | -| [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) | done | the build, the allowance and the output carried in are cased quantities, so fixed, extendable and committed are one block; big-M is rung 8's | +| [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) | done | the build, the allowance and the output carried in are cased quantities, so fixed, extendable and committed are one block; big-M is rung 8's. A missing limit reads as the full build, and a start-up or shut-down ramp alone builds the row, rung 28 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `8785.0`, 64 rows. @@ -676,7 +676,7 @@ def build(): | [`{c}-com-mod-p-lower/upper`](#generator-com-mod-p-lower) | done | one module's share, times the status — a fixed build too, beside its ordinary `com-p-*` rows | | [`{c}-com-ext-p-*` (big-M)](#generator-com-ext-p-upper-cap) | done | a cap row beside a big-M row; `M` is the build cap at full availability, data prep | | [`{c}-com-ext-p-lower-nonneg`](#generator-com-ext-p-lower-nonneg) | done | `(p_min_pu >= 0).all()` is prep | -| [`{c}-p-ramp_limit_*-bigM`](#generator-p-ramp_limit_up-run-bigm) | done | run and start rows up, run and shut rows down; the output carried in is a cased quantity, so each is one block | +| [`{c}-p-ramp_limit_*-bigM`](#generator-p-ramp_limit_up-run-bigm) | done | run and start rows up, run and shut rows down; the output carried in is a cased quantity, so each is one block. A modular build takes the ordinary rows against one module instead, rung 27 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `15915.0`, 191 rows. @@ -2278,6 +2278,125 @@ def build(): +### Rung 27 — modular ramps + +A committable, extendable and modular unit gets no big-M ramp rows. PyPSA gives +it the ordinary `{c}-p-ramp_limit_*` rows of a committed unit, with one module +`p_nom_mod` in place of `p_nom`. The status counts the modules that are on, so +the allowance grows with each module. This rung has one such generator, link +and process on a peak bus, each with a ramp limit of one half and a start-up +and shut-down ramp of 0.6. The ramp rows bind at the rise and at the fall of +the load. + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-p-ramp_limit_up/down`, modular](#generator-p-ramp_limit_up) | done | the committed allowance reads `p_nom_committed`: one module where the build is extendable and modular, `p_nom` otherwise | +| [`{c}-p-ramp_limit_*-bigM`, modular](#generator-p-ramp_limit_up-run-bigm) | done | not built for a modular build | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `45469.49999999998`, 161 rows. + +
+The network, as PyPSA code + +`rung_27_modular_ramp.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 27: modular ramps — a committable extendable modular unit ramps against one module through the ordinary ramp rows, not the big-M ones.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a peak bus served by a committable modular generator, link and process, each ramp-limited, with a dear backup.""" + n = spine.build() + n.add('Bus', 'peak') + common = { + 'committable': True, + 'p_nom_extendable': True, + 'p_nom_mod': 20, + 'p_nom_max': 60, + 'capital_cost': 2, + 'p_min_pu': 0.2, + 'up_time_before': 0, + 'ramp_limit_up': 0.5, + 'ramp_limit_down': 0.5, + 'ramp_limit_start_up': 0.6, + 'ramp_limit_shut_down': 0.6, + } + n.add('Generator', 'mod_gen', bus='peak', marginal_cost=3, **common) + n.add('Link', 'mod_link', bus0='north', bus1='peak', marginal_cost=4, **common) + n.add('Process', 'mod_proc', bus0='south', bus1='peak', rate0=-1.25, marginal_cost=5, **common) + n.add('Generator', 'peak_backup', bus='peak', p_nom=200, marginal_cost=500) + n.add('Load', 'peak_load', bus='peak', p_set=[10, 90, 150, 20]) + return n +``` + +
+ + +### Rung 28 — a start-up ramp alone + +PyPSA builds a ramp row where either the ramp limit or the start-up ramp is +given, and reads the missing one as `1.0`, the full build. The down row is the +same with the shut-down ramp. This rung has a committable generator, link and +process that carry only a start-up ramp of 0.4 and a shut-down ramp of 0.5. The +start-up ramp caps the snapshot each unit turns on, and the shut-down ramp caps +the snapshot before it turns off. + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-p-ramp_limit_up/down`, start-up or shut-down ramp alone](#generator-p-ramp_limit_up) | done | the `where:` reads either limit; `ramp_up_rate` and its three siblings read a missing one as `1` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `83282.99999999983`, 152 rows. + +
+The network, as PyPSA code + +`rung_28_start_up_ramp.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 28: a start-up ramp alone — a committable unit with only a start-up and a shut-down ramp still gets ramp rows, at the full build between them.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a pulse bus served by committable generator, link and process that carry only start-up and shut-down ramps, with a dear backup.""" + n = spine.build() + n.add('Bus', 'pulse') + common = { + 'committable': True, + 'p_nom': 40, + 'p_min_pu': 0.1, + 'up_time_before': 0, + 'ramp_limit_start_up': 0.4, + 'ramp_limit_shut_down': 0.5, + } + n.add('Generator', 'pulse_gen', bus='pulse', marginal_cost=3, **common) + n.add('Link', 'pulse_link', bus0='north', bus1='pulse', marginal_cost=4, **common) + n.add('Process', 'pulse_proc', bus0='south', bus1='pulse', rate0=-1.25, marginal_cost=5, **common) + n.add('Generator', 'pulse_backup', bus='pulse', p_nom=200, marginal_cost=500) + n.add('Load', 'pulse_load', bus='pulse', p_set=[0, 60, 110, 0]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2585,16 +2704,31 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | | $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\mathcal{G}`$ — the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — nothing at the start of the horizon, which is why a link that came in running carries no ramp row there | +| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}`$ | `Link_shut_down_rate` over $`\mathcal{L}`$ — the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\mathcal{L}`$ — the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{f,+}`$ | `Link_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may raise flow between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — nothing at the start of the horizon, which is why a process that came in running carries no ramp row there | +| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}`$ | `Process_shut_down_rate` over $`\mathcal{J}`$ — the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | @@ -3253,16 +3387,17 @@ Generator_p_ramp_limit_up_run_big_m: releases the row in the snapshot it turns on dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_up * Generator_p_nom_ext + Generator_ramp_up_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_previous_status ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \mathrm{ru}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -3277,16 +3412,17 @@ Generator_p_ramp_limit_up_start_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_start_up * Generator_p_nom_ext + Generator_start_up_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_start_up ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \mathrm{ru}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -3301,16 +3437,17 @@ Generator_p_ramp_limit_down_run_big_m: releases the row in the snapshot it turns off dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_down * Generator_p_nom_ext + Generator_ramp_down_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_status ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \mathrm{rd}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -3325,16 +3462,17 @@ Generator_p_ramp_limit_down_shut_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_shut_down * Generator_p_nom_ext + Generator_shut_down_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_shut_down ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \mathrm{rd}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -3718,16 +3856,17 @@ Link_p_ramp_limit_up_run_big_m: releases the row in the snapshot it turns on dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_p - Link_previous_p <= - Link_ramp_limit_up * Link_p_nom_ext + Link_ramp_up_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_previous_status ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \mathrm{ru}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-start-bigM` @@ -3742,16 +3881,17 @@ Link_p_ramp_limit_up_start_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_p - Link_previous_p <= - Link_ramp_limit_start_up * Link_p_nom_ext + Link_start_up_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_start_up ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \mathrm{ru}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-run-bigM` @@ -3766,16 +3906,17 @@ Link_p_ramp_limit_down_run_big_m: releases the row in the snapshot it turns off dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_previous_p - Link_p <= - Link_ramp_limit_down * Link_p_nom_ext + Link_ramp_down_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_status ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \mathrm{rd}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-shut-bigM` @@ -3790,16 +3931,17 @@ Link_p_ramp_limit_down_shut_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_previous_p - Link_p <= - Link_ramp_limit_shut_down * Link_p_nom_ext + Link_shut_down_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_shut_down ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \mathrm{rd}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p_nom_modularity` @@ -4183,16 +4325,17 @@ Process_p_ramp_limit_up_run_big_m: releases the row in the snapshot it turns on dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_p - Process_previous_p <= - Process_ramp_limit_up * Process_p_nom_ext + Process_ramp_up_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_previous_status ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \mathrm{ru}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-start-bigM` @@ -4207,16 +4350,17 @@ Process_p_ramp_limit_up_start_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_p - Process_previous_p <= - Process_ramp_limit_start_up * Process_p_nom_ext + Process_start_up_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_start_up ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \mathrm{ru}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-run-bigM` @@ -4231,16 +4375,17 @@ Process_p_ramp_limit_down_run_big_m: releases the row in the snapshot it turns off dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_previous_p - Process_p <= - Process_ramp_limit_down * Process_p_nom_ext + Process_ramp_down_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_status ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \mathrm{rd}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-shut-bigM` @@ -4255,16 +4400,17 @@ Process_p_ramp_limit_down_shut_big_m: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_previous_p - Process_p <= - Process_ramp_limit_shut_down * Process_p_nom_ext + Process_shut_down_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_shut_down ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \mathrm{rd}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p_nom_modularity` @@ -4902,14 +5048,14 @@ Generator_p_ramp_limit_up: first snapshot — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- - Generator_ramp_limit_up - AND NOT (Generator_committable AND Generator_p_nom_extendable) + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ru}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -4926,14 +5072,14 @@ Generator_p_ramp_limit_down: first snapshot — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- - Generator_ramp_limit_down - AND NOT (Generator_committable AND Generator_p_nom_extendable) + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{rd}_{g} \text{ is defined} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -4950,14 +5096,14 @@ Link_p_ramp_limit_up: first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- - Link_ramp_limit_up - AND NOT (Link_committable AND Link_p_nom_extendable) + (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ru}^{f}_{l} \text{ is defined} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -4974,14 +5120,14 @@ Link_p_ramp_limit_down: first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- - Link_ramp_limit_down - AND NOT (Link_committable AND Link_p_nom_extendable) + (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{rd}^{f}_{l} \text{ is defined} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -4998,14 +5144,14 @@ Process_p_ramp_limit_up: first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- - Process_ramp_limit_up - AND NOT (Process_committable AND Process_p_nom_extendable) + (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ru}^{z}_{j} \text{ is defined} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -5022,14 +5168,14 @@ Process_p_ramp_limit_down: first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- - Process_ramp_limit_down - AND NOT (Process_committable AND Process_p_nom_extendable) + (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{rd}^{z}_{j} \text{ is defined} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -5811,6 +5957,91 @@ Generator_p_nom_effective: \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} = \begin{cases} P_{g} & \text{if } \mathrm{ext}_{g} \\ \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} ``` +### `Generator_ramp_up_rate` + +```yaml +Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}_{g} = \begin{cases} \mathrm{ru}_{g} & \text{if } \mathrm{ru}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_ramp_down_rate` + +```yaml +Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}_{g} = \begin{cases} \mathrm{rd}_{g} & \text{if } \mathrm{rd}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_start_up_rate` + +```yaml +Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} = \begin{cases} \mathrm{ru}^{\mathrm{up}}_{g} & \text{if } \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_shut_down_rate` + +```yaml +Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_p_nom_committed` + +```yaml +Generator_p_nom_committed: + description: >- + the build a committed unit's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [generator] + cases: + modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } + otherwise: Generator_p_nom +``` + +```math +\widehat{\mathrm{p}}^{\mathrm{nom}}_{g} = \begin{cases} \mathrm{p}^{\mathrm{mod}}_{g} & \text{if } \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \\ \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + ### `Generator_ramp_up_allowance` ```yaml @@ -5824,14 +6055,14 @@ Generator_ramp_up_allowance: committed: when: Generator_committable expression: >- - Generator_ramp_limit_up * Generator_p_nom * Generator_previous_status - + Generator_ramp_limit_start_up * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom_committed * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom_committed * (Generator_status - Generator_previous_status) - otherwise: Generator_ramp_limit_up * Generator_p_nom_effective + otherwise: Generator_ramp_up_rate * Generator_p_nom_effective ``` ```math -\Delta^{+}_{\xi,t,g} = \begin{cases} \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{\xi,t,g} + \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{ru}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_allowance` @@ -5847,14 +6078,14 @@ Generator_ramp_down_allowance: committed: when: Generator_committable expression: >- - Generator_ramp_limit_down * Generator_p_nom * Generator_status - + Generator_ramp_limit_shut_down * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom_committed * Generator_status + + Generator_shut_down_rate * Generator_p_nom_committed * (Generator_previous_status - Generator_status) - otherwise: Generator_ramp_limit_down * Generator_p_nom_effective + otherwise: Generator_ramp_down_rate * Generator_p_nom_effective ``` ```math -\Delta^{-}_{\xi,t,g} = \begin{cases} \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} + \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \mathrm{rd}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Link_p_nom_effective` @@ -5907,6 +6138,91 @@ Link_previous_p: \overleftarrow{f}_{\xi,t,l} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ f_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` +### `Link_ramp_up_rate` + +```yaml +Link_ramp_up_rate: + description: >- + the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [link] + cases: + given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{f}_{l} = \begin{cases} \mathrm{ru}^{f}_{l} & \text{if } \mathrm{ru}^{f}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +``` + +### `Link_ramp_down_rate` + +```yaml +Link_ramp_down_rate: + description: >- + the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [link] + cases: + given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{f}_{l} = \begin{cases} \mathrm{rd}^{f}_{l} & \text{if } \mathrm{rd}^{f}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +``` + +### `Link_start_up_rate` + +```yaml +Link_start_up_rate: + description: >- + the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [link] + cases: + given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} = \begin{cases} \mathrm{ru}^{f,\mathrm{up}}_{l} & \text{if } \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +``` + +### `Link_shut_down_rate` + +```yaml +Link_shut_down_rate: + description: >- + the shut-down ramp a link's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [link] + cases: + given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} = \begin{cases} \mathrm{rd}^{f,\mathrm{dn}}_{l} & \text{if } \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +``` + +### `Link_p_nom_committed` + +```yaml +Link_p_nom_committed: + description: >- + the build a committed link's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [link] + cases: + modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } + otherwise: Link_p_nom +``` + +```math +\widehat{\mathrm{f}}^{\mathrm{nom}}_{l} = \begin{cases} \mathrm{f}^{\mathrm{mod}}_{l} & \text{if } \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \\ \mathrm{f}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +``` + ### `Link_ramp_up_allowance` ```yaml @@ -5920,14 +6236,14 @@ Link_ramp_up_allowance: committed: when: Link_committable expression: >- - Link_ramp_limit_up * Link_p_nom * Link_previous_status - + Link_ramp_limit_start_up * Link_p_nom + Link_ramp_up_rate * Link_p_nom_committed * Link_previous_status + + Link_start_up_rate * Link_p_nom_committed * (Link_status - Link_previous_status) - otherwise: Link_ramp_limit_up * Link_p_nom_effective + otherwise: Link_ramp_up_rate * Link_p_nom_effective ``` ```math -\Delta^{f,+}_{\xi,t,l} = \begin{cases} \mathrm{ru}^{f}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \mathrm{ru}^{f,\mathrm{up}}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \mathrm{ru}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_ramp_down_allowance` @@ -5943,14 +6259,14 @@ Link_ramp_down_allowance: committed: when: Link_committable expression: >- - Link_ramp_limit_down * Link_p_nom * Link_status - + Link_ramp_limit_shut_down * Link_p_nom + Link_ramp_down_rate * Link_p_nom_committed * Link_status + + Link_shut_down_rate * Link_p_nom_committed * (Link_previous_status - Link_status) - otherwise: Link_ramp_limit_down * Link_p_nom_effective + otherwise: Link_ramp_down_rate * Link_p_nom_effective ``` ```math -\Delta^{f,-}_{\xi,t,l} = \begin{cases} \mathrm{rd}^{f}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} + \mathrm{rd}^{f,\mathrm{dn}}_{l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \mathrm{rd}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Process_p_nom_effective` @@ -6003,6 +6319,91 @@ Process_previous_p: \overleftarrow{z}_{\xi,t,j} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ z_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` +### `Process_ramp_up_rate` + +```yaml +Process_ramp_up_rate: + description: >- + the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [process] + cases: + given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{z}_{j} = \begin{cases} \mathrm{ru}^{z}_{j} & \text{if } \mathrm{ru}^{z}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + +### `Process_ramp_down_rate` + +```yaml +Process_ramp_down_rate: + description: >- + the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [process] + cases: + given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{z}_{j} = \begin{cases} \mathrm{rd}^{z}_{j} & \text{if } \mathrm{rd}^{z}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + +### `Process_start_up_rate` + +```yaml +Process_start_up_rate: + description: >- + the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [process] + cases: + given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} = \begin{cases} \mathrm{ru}^{z,\mathrm{up}}_{j} & \text{if } \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + +### `Process_shut_down_rate` + +```yaml +Process_shut_down_rate: + description: >- + the shut-down ramp a process's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [process] + cases: + given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} = \begin{cases} \mathrm{rd}^{z,\mathrm{dn}}_{j} & \text{if } \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + +### `Process_p_nom_committed` + +```yaml +Process_p_nom_committed: + description: >- + the build a committed process's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [process] + cases: + modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } + otherwise: Process_p_nom +``` + +```math +\widehat{\mathrm{z}}^{\mathrm{nom}}_{j} = \begin{cases} \mathrm{z}^{\mathrm{mod}}_{j} & \text{if } \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \\ \mathrm{z}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +``` + ### `Process_ramp_up_allowance` ```yaml @@ -6016,14 +6417,14 @@ Process_ramp_up_allowance: committed: when: Process_committable expression: >- - Process_ramp_limit_up * Process_p_nom * Process_previous_status - + Process_ramp_limit_start_up * Process_p_nom + Process_ramp_up_rate * Process_p_nom_committed * Process_previous_status + + Process_start_up_rate * Process_p_nom_committed * (Process_status - Process_previous_status) - otherwise: Process_ramp_limit_up * Process_p_nom_effective + otherwise: Process_ramp_up_rate * Process_p_nom_effective ``` ```math -\Delta^{z,+}_{\xi,t,j} = \begin{cases} \mathrm{ru}^{z}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \mathrm{ru}^{z,\mathrm{up}}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \mathrm{ru}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_ramp_down_allowance` @@ -6039,14 +6440,14 @@ Process_ramp_down_allowance: committed: when: Process_committable expression: >- - Process_ramp_limit_down * Process_p_nom * Process_status - + Process_ramp_limit_shut_down * Process_p_nom + Process_ramp_down_rate * Process_p_nom_committed * Process_status + + Process_shut_down_rate * Process_p_nom_committed * (Process_previous_status - Process_status) - otherwise: Process_ramp_limit_down * Process_p_nom_effective + otherwise: Process_ramp_down_rate * Process_p_nom_effective ``` ```math -\Delta^{z,-}_{\xi,t,j} = \begin{cases} \mathrm{rd}^{z}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} + \mathrm{rd}^{z,\mathrm{dn}}_{j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \mathrm{rd}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `StorageUnit_charge_carried_in` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 5f8490f3..289379ab 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -1193,6 +1193,46 @@ expressions: cases: extendable: { when: Generator_p_nom_extendable, expression: Generator_p_nom_ext } otherwise: Generator_p_nom + Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 + Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 + Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 + Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 + Generator_p_nom_committed: + description: >- + the build a committed unit's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [generator] + cases: + modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } + otherwise: Generator_p_nom Generator_ramp_up_allowance: description: >- how far a generator may raise output between two snapshots — its ramp @@ -1203,10 +1243,10 @@ expressions: committed: when: Generator_committable expression: >- - Generator_ramp_limit_up * Generator_p_nom * Generator_previous_status - + Generator_ramp_limit_start_up * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom_committed * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom_committed * (Generator_status - Generator_previous_status) - otherwise: Generator_ramp_limit_up * Generator_p_nom_effective + otherwise: Generator_ramp_up_rate * Generator_p_nom_effective Generator_ramp_down_allowance: description: >- how far a generator may lower output between two snapshots — its ramp @@ -1217,10 +1257,10 @@ expressions: committed: when: Generator_committable expression: >- - Generator_ramp_limit_down * Generator_p_nom * Generator_status - + Generator_ramp_limit_shut_down * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom_committed * Generator_status + + Generator_shut_down_rate * Generator_p_nom_committed * (Generator_previous_status - Generator_status) - otherwise: Generator_ramp_limit_down * Generator_p_nom_effective + otherwise: Generator_ramp_down_rate * Generator_p_nom_effective Link_p_nom_effective: description: the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise dims: [link] @@ -1244,6 +1284,46 @@ expressions: cases: opening: { when: "position(snapshot) == 0", expression: 0 } otherwise: shift(Link_p, along=snapshot, offset=1) + Link_ramp_up_rate: + description: >- + the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [link] + cases: + given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } + otherwise: 1 + Link_ramp_down_rate: + description: >- + the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [link] + cases: + given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } + otherwise: 1 + Link_start_up_rate: + description: >- + the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [link] + cases: + given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } + otherwise: 1 + Link_shut_down_rate: + description: >- + the shut-down ramp a link's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [link] + cases: + given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } + otherwise: 1 + Link_p_nom_committed: + description: >- + the build a committed link's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [link] + cases: + modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } + otherwise: Link_p_nom Link_ramp_up_allowance: description: >- how far a link may raise flow between two snapshots — its ramp @@ -1254,10 +1334,10 @@ expressions: committed: when: Link_committable expression: >- - Link_ramp_limit_up * Link_p_nom * Link_previous_status - + Link_ramp_limit_start_up * Link_p_nom + Link_ramp_up_rate * Link_p_nom_committed * Link_previous_status + + Link_start_up_rate * Link_p_nom_committed * (Link_status - Link_previous_status) - otherwise: Link_ramp_limit_up * Link_p_nom_effective + otherwise: Link_ramp_up_rate * Link_p_nom_effective Link_ramp_down_allowance: description: >- how far a link may lower flow between two snapshots — its ramp @@ -1268,10 +1348,10 @@ expressions: committed: when: Link_committable expression: >- - Link_ramp_limit_down * Link_p_nom * Link_status - + Link_ramp_limit_shut_down * Link_p_nom + Link_ramp_down_rate * Link_p_nom_committed * Link_status + + Link_shut_down_rate * Link_p_nom_committed * (Link_previous_status - Link_status) - otherwise: Link_ramp_limit_down * Link_p_nom_effective + otherwise: Link_ramp_down_rate * Link_p_nom_effective Process_p_nom_effective: description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise dims: [process] @@ -1295,6 +1375,46 @@ expressions: cases: opening: { when: "position(snapshot) == 0", expression: 0 } otherwise: shift(Process_p, along=snapshot, offset=1) + Process_ramp_up_rate: + description: >- + the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [process] + cases: + given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } + otherwise: 1 + Process_ramp_down_rate: + description: >- + the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [process] + cases: + given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } + otherwise: 1 + Process_start_up_rate: + description: >- + the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [process] + cases: + given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } + otherwise: 1 + Process_shut_down_rate: + description: >- + the shut-down ramp a process's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [process] + cases: + given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } + otherwise: 1 + Process_p_nom_committed: + description: >- + the build a committed process's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [process] + cases: + modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } + otherwise: Process_p_nom Process_ramp_up_allowance: description: >- how far a process may raise internal power between two snapshots — its ramp @@ -1305,10 +1425,10 @@ expressions: committed: when: Process_committable expression: >- - Process_ramp_limit_up * Process_p_nom * Process_previous_status - + Process_ramp_limit_start_up * Process_p_nom + Process_ramp_up_rate * Process_p_nom_committed * Process_previous_status + + Process_start_up_rate * Process_p_nom_committed * (Process_status - Process_previous_status) - otherwise: Process_ramp_limit_up * Process_p_nom_effective + otherwise: Process_ramp_up_rate * Process_p_nom_effective Process_ramp_down_allowance: description: >- how far a process may lower internal power between two snapshots — its ramp @@ -1319,10 +1439,10 @@ expressions: committed: when: Process_committable expression: >- - Process_ramp_limit_down * Process_p_nom * Process_status - + Process_ramp_limit_shut_down * Process_p_nom + Process_ramp_down_rate * Process_p_nom_committed * Process_status + + Process_shut_down_rate * Process_p_nom_committed * (Process_previous_status - Process_status) - otherwise: Process_ramp_limit_down * Process_p_nom_effective + otherwise: Process_ramp_down_rate * Process_p_nom_effective StorageUnit_charge_carried_in: description: >- the charge a unit opens a snapshot with — its last snapshot's less @@ -1657,11 +1777,12 @@ constraints: releases the row in the snapshot it turns on dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_up * Generator_p_nom_ext + Generator_ramp_up_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_previous_status Generator_p_ramp_limit_up_start_big_m: description: >- @@ -1670,11 +1791,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_up + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_start_up * Generator_p_nom_ext + Generator_start_up_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_start_up Generator_p_ramp_limit_down_run_big_m: description: >- @@ -1683,11 +1805,12 @@ constraints: releases the row in the snapshot it turns off dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_down * Generator_p_nom_ext + Generator_ramp_down_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_status Generator_p_ramp_limit_down_shut_big_m: description: >- @@ -1696,11 +1819,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, generator] where: >- - Generator_committable AND Generator_p_nom_extendable AND Generator_ramp_limit_down + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_shut_down * Generator_p_nom_ext + Generator_shut_down_rate * Generator_p_nom_ext + Generator_big_m - Generator_big_m * Generator_shut_down Generator_p_nom_modularity: description: "`Generator-p_nom_modularity` — the chosen build is a whole number of modules" @@ -1847,11 +1971,12 @@ constraints: releases the row in the snapshot it turns on dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_p - Link_previous_p <= - Link_ramp_limit_up * Link_p_nom_ext + Link_ramp_up_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_previous_status Link_p_ramp_limit_up_start_big_m: description: >- @@ -1860,11 +1985,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_up + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_p - Link_previous_p <= - Link_ramp_limit_start_up * Link_p_nom_ext + Link_start_up_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_start_up Link_p_ramp_limit_down_run_big_m: description: >- @@ -1873,11 +1999,12 @@ constraints: releases the row in the snapshot it turns off dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_previous_p - Link_p <= - Link_ramp_limit_down * Link_p_nom_ext + Link_ramp_down_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_status Link_p_ramp_limit_down_shut_big_m: description: >- @@ -1886,11 +2013,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, link] where: >- - Link_committable AND Link_p_nom_extendable AND Link_ramp_limit_down + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active expression: >- Link_previous_p - Link_p <= - Link_ramp_limit_shut_down * Link_p_nom_ext + Link_shut_down_rate * Link_p_nom_ext + Link_big_m - Link_big_m * Link_shut_down Link_p_nom_modularity: description: "`Link-p_nom_modularity` — the chosen build is a whole number of modules" @@ -2037,11 +2165,12 @@ constraints: releases the row in the snapshot it turns on dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_p - Process_previous_p <= - Process_ramp_limit_up * Process_p_nom_ext + Process_ramp_up_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_previous_status Process_p_ramp_limit_up_start_big_m: description: >- @@ -2050,11 +2179,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_up + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_p - Process_previous_p <= - Process_ramp_limit_start_up * Process_p_nom_ext + Process_start_up_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_start_up Process_p_ramp_limit_down_run_big_m: description: >- @@ -2063,11 +2193,12 @@ constraints: releases the row in the snapshot it turns off dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_previous_p - Process_p <= - Process_ramp_limit_down * Process_p_nom_ext + Process_ramp_down_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_status Process_p_ramp_limit_down_shut_big_m: description: >- @@ -2076,11 +2207,12 @@ constraints: the chosen build; the big M releases the row everywhere else dims: [scenario, snapshot, process] where: >- - Process_committable AND Process_p_nom_extendable AND Process_ramp_limit_down + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active expression: >- Process_previous_p - Process_p <= - Process_ramp_limit_shut_down * Process_p_nom_ext + Process_shut_down_rate * Process_p_nom_ext + Process_big_m - Process_big_m * Process_shut_down Process_p_nom_modularity: description: "`Process-p_nom_modularity` — the chosen build is a whole number of modules" @@ -2316,8 +2448,8 @@ constraints: first snapshot — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- - Generator_ramp_limit_up - AND NOT (Generator_committable AND Generator_p_nom_extendable) + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance Generator_p_ramp_limit_down: @@ -2329,8 +2461,8 @@ constraints: first snapshot — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- - Generator_ramp_limit_down - AND NOT (Generator_committable AND Generator_p_nom_extendable) + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Link_p_ramp_limit_up: @@ -2342,8 +2474,8 @@ constraints: first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- - Link_ramp_limit_up - AND NOT (Link_committable AND Link_p_nom_extendable) + (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance Link_p_ramp_limit_down: @@ -2355,8 +2487,8 @@ constraints: first snapshot — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- - Link_ramp_limit_down - AND NOT (Link_committable AND Link_p_nom_extendable) + (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance Process_p_ramp_limit_up: @@ -2368,8 +2500,8 @@ constraints: first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- - Process_ramp_limit_up - AND NOT (Process_committable AND Process_p_nom_extendable) + (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance Process_p_ramp_limit_down: @@ -2381,8 +2513,8 @@ constraints: first snapshot — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- - Process_ramp_limit_down - AND NOT (Process_committable AND Process_p_nom_extendable) + (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance StorageUnit_ext_p_dispatch_lower: diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index e464a0c0..c2d2677e 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -951,5 +951,128 @@ "Process-status-p-fixed-upper": 16, "Process-status-p_nom-variable-upper": 4 } + }, + "rung_27_modular_ramp": { + "columns": { + "Generator-n_mod": 1, + "Generator-p": 16, + "Generator-p_nom": 1, + "Generator-shut_down": 4, + "Generator-start_up": 4, + "Generator-status": 4, + "Link-n_mod": 1, + "Link-p": 8, + "Link-p_nom": 1, + "Link-shut_down": 4, + "Link-start_up": 4, + "Link-status": 4, + "Process-n_mod": 1, + "Process-p": 4, + "Process-p_nom": 1, + "Process-shut_down": 4, + "Process-start_up": 4, + "Process-status": 4 + }, + "global_constraints": {}, + "objective": 45469.49999999998, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-com-mod-p-lower": 4, + "Generator-com-mod-p-upper": 4, + "Generator-com-transition-shut-down": 4, + "Generator-com-transition-start-up": 4, + "Generator-ext-p_nom-lower": 1, + "Generator-ext-p_nom-upper": 1, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Generator-p-ramp_limit_down": 4, + "Generator-p-ramp_limit_up": 4, + "Generator-p_nom_modularity": 1, + "Generator-shut_down-p_nom-variable-upper": 4, + "Generator-start_up-p_nom-variable-upper": 4, + "Generator-status-p_nom-variable-upper": 4, + "Link-com-mod-p-lower": 4, + "Link-com-mod-p-upper": 4, + "Link-com-transition-shut-down": 4, + "Link-com-transition-start-up": 4, + "Link-ext-p_nom-lower": 1, + "Link-ext-p_nom-upper": 1, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Link-p-ramp_limit_down": 4, + "Link-p-ramp_limit_up": 4, + "Link-p_nom_modularity": 1, + "Link-shut_down-p_nom-variable-upper": 4, + "Link-start_up-p_nom-variable-upper": 4, + "Link-status-p_nom-variable-upper": 4, + "Process-com-mod-p-lower": 4, + "Process-com-mod-p-upper": 4, + "Process-com-transition-shut-down": 4, + "Process-com-transition-start-up": 4, + "Process-ext-p_nom-lower": 1, + "Process-ext-p_nom-upper": 1, + "Process-p-ramp_limit_down": 4, + "Process-p-ramp_limit_up": 4, + "Process-p_nom_modularity": 1, + "Process-shut_down-p_nom-variable-upper": 4, + "Process-start_up-p_nom-variable-upper": 4, + "Process-status-p_nom-variable-upper": 4 + } + }, + "rung_28_start_up_ramp": { + "columns": { + "Generator-p": 16, + "Generator-shut_down": 4, + "Generator-start_up": 4, + "Generator-status": 4, + "Link-p": 8, + "Link-shut_down": 4, + "Link-start_up": 4, + "Link-status": 4, + "Process-p": 4, + "Process-shut_down": 4, + "Process-start_up": 4, + "Process-status": 4 + }, + "global_constraints": {}, + "objective": 83282.99999999983, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-com-p-lower": 4, + "Generator-com-p-upper": 4, + "Generator-com-transition-shut-down": 4, + "Generator-com-transition-start-up": 4, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Generator-p-ramp_limit_down": 4, + "Generator-p-ramp_limit_up": 4, + "Generator-shut_down-p-fixed-upper": 4, + "Generator-start_up-p-fixed-upper": 4, + "Generator-status-p-fixed-upper": 4, + "Link-com-p-lower": 4, + "Link-com-p-upper": 4, + "Link-com-transition-shut-down": 4, + "Link-com-transition-start-up": 4, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Link-p-ramp_limit_down": 4, + "Link-p-ramp_limit_up": 4, + "Link-shut_down-p-fixed-upper": 4, + "Link-start_up-p-fixed-upper": 4, + "Link-status-p-fixed-upper": 4, + "Process-com-p-lower": 4, + "Process-com-p-upper": 4, + "Process-com-transition-shut-down": 4, + "Process-com-transition-start-up": 4, + "Process-p-ramp_limit_down": 4, + "Process-p-ramp_limit_up": 4, + "Process-shut_down-p-fixed-upper": 4, + "Process-start_up-p-fixed-upper": 4, + "Process-status-p-fixed-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_27_modular_ramp.py b/examples/references/pypsa/rung_27_modular_ramp.py new file mode 100644 index 00000000..54bdee66 --- /dev/null +++ b/examples/references/pypsa/rung_27_modular_ramp.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 27: modular ramps — a committable extendable modular unit ramps against one module through the ordinary ramp rows, not the big-M ones.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a peak bus served by a committable modular generator, link and process, each ramp-limited, with a dear backup.""" + n = spine.build() + n.add('Bus', 'peak') + common = { + 'committable': True, + 'p_nom_extendable': True, + 'p_nom_mod': 20, + 'p_nom_max': 60, + 'capital_cost': 2, + 'p_min_pu': 0.2, + 'up_time_before': 0, + 'ramp_limit_up': 0.5, + 'ramp_limit_down': 0.5, + 'ramp_limit_start_up': 0.6, + 'ramp_limit_shut_down': 0.6, + } + n.add('Generator', 'mod_gen', bus='peak', marginal_cost=3, **common) + n.add('Link', 'mod_link', bus0='north', bus1='peak', marginal_cost=4, **common) + n.add('Process', 'mod_proc', bus0='south', bus1='peak', rate0=-1.25, marginal_cost=5, **common) + n.add('Generator', 'peak_backup', bus='peak', p_nom=200, marginal_cost=500) + n.add('Load', 'peak_load', bus='peak', p_set=[10, 90, 150, 20]) + return n diff --git a/examples/references/pypsa/rung_28_start_up_ramp.py b/examples/references/pypsa/rung_28_start_up_ramp.py new file mode 100644 index 00000000..cfabe91f --- /dev/null +++ b/examples/references/pypsa/rung_28_start_up_ramp.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 28: a start-up ramp alone — a committable unit with only a start-up and a shut-down ramp still gets ramp rows, at the full build between them.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a pulse bus served by committable generator, link and process that carry only start-up and shut-down ramps, with a dear backup.""" + n = spine.build() + n.add('Bus', 'pulse') + common = { + 'committable': True, + 'p_nom': 40, + 'p_min_pu': 0.1, + 'up_time_before': 0, + 'ramp_limit_start_up': 0.4, + 'ramp_limit_shut_down': 0.5, + } + n.add('Generator', 'pulse_gen', bus='pulse', marginal_cost=3, **common) + n.add('Link', 'pulse_link', bus0='north', bus1='pulse', marginal_cost=4, **common) + n.add('Process', 'pulse_proc', bus0='south', bus1='pulse', rate0=-1.25, marginal_cost=5, **common) + n.add('Generator', 'pulse_backup', bus='pulse', p_nom=200, marginal_cost=500) + n.add('Load', 'pulse_load', bus='pulse', p_set=[0, 60, 110, 0]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 5705fda4..a8e2704e 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -46,6 +46,11 @@ names: Generator_previous_p: '\overleftarrow{p}' Generator_ramp_up_allowance: '\Delta^{+}' Generator_ramp_down_allowance: '\Delta^{-}' + Generator_ramp_up_rate: '\widetilde{\mathrm{ru}}' + Generator_ramp_down_rate: '\widetilde{\mathrm{rd}}' + Generator_start_up_rate: '\widetilde{\mathrm{ru}}^{\mathrm{up}}' + Generator_shut_down_rate: '\widetilde{\mathrm{rd}}^{\mathrm{dn}}' + Generator_p_nom_committed: '\widehat{\mathrm{p}}^{\mathrm{nom}}' Generator_must_stay_up: '\mathrm{hold}' Generator_must_stay_down: '\mathrm{rest}' Generator_start_up_cost: '\mathrm{c}^{\mathrm{up}}' @@ -81,6 +86,11 @@ names: Link_previous_p: '\overleftarrow{f}' Link_ramp_up_allowance: '\Delta^{f,+}' Link_ramp_down_allowance: '\Delta^{f,-}' + Link_ramp_up_rate: '\widetilde{\mathrm{ru}}^{f}' + Link_ramp_down_rate: '\widetilde{\mathrm{rd}}^{f}' + Link_start_up_rate: '\widetilde{\mathrm{ru}}^{f,\mathrm{up}}' + Link_shut_down_rate: '\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}' + Link_p_nom_committed: '\widehat{\mathrm{f}}^{\mathrm{nom}}' Link_must_stay_up: '\mathrm{hold}^{f}' Link_must_stay_down: '\mathrm{rest}^{f}' Link_start_up_cost: '\mathrm{c}^{f,\mathrm{up}}' @@ -143,6 +153,11 @@ names: Process_previous_p: '\overleftarrow{z}' Process_ramp_up_allowance: '\Delta^{z,+}' Process_ramp_down_allowance: '\Delta^{z,-}' + Process_ramp_up_rate: '\widetilde{\mathrm{ru}}^{z}' + Process_ramp_down_rate: '\widetilde{\mathrm{rd}}^{z}' + Process_start_up_rate: '\widetilde{\mathrm{ru}}^{z,\mathrm{up}}' + Process_shut_down_rate: '\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}' + Process_p_nom_committed: '\widehat{\mathrm{z}}^{\mathrm{nom}}' Process_must_stay_up: '\mathrm{hold}^{z}' Process_must_stay_down: '\mathrm{rest}^{z}' Process_start_up_cost: '\mathrm{c}^{z,\mathrm{up}}' From 805d4805e0f24d5ff06a05c917e8b63123475565 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 13:02:31 +0200 Subject: [PATCH 14/75] docs(pypsa): storage cycles or reopens per investment period, and a ramp restarts at a period start --- docs/examples/pypsa.md | 233 ++++++++++++++---- examples/pypsa.yaml | 128 +++++++--- examples/references/pypsa/references.json | 31 +++ .../pypsa/rung_29_storage_per_period.py | 45 ++++ examples/symbols/pypsa.yaml | 4 + 5 files changed, 358 insertions(+), 83 deletions(-) create mode 100644 examples/references/pypsa/rung_29_storage_per_period.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index f22c2b8f..d6c8d4e8 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1357,7 +1357,7 @@ own dimensions. | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | -| `StorageUnit-energy_balance` per period, ramps at period starts | out | `shift(…, by=snapshot_period, within=period)` has them; a later rung | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. @@ -2397,6 +2397,87 @@ def build(): +### Rung 29 — storage per investment period + +`n.optimize(multi_investment_periods=True)` with storage that treats each +investment period as a horizon of its own. A storage unit with +`cyclic_state_of_charge_per_period` and a store with `e_cyclic_per_period` +close each period on itself: the first snapshot of a period carries in the +level of that period's last snapshot. A storage unit with +`state_of_charge_initial_per_period` and a store with `e_initial_per_period` +open each period on their initial level. The per-period cyclic flag overrides +the global one and the per-period initial flag. PyPSA reads the four flags only +under `multi_investment_periods`, so data prep feeds false on a plain run. + +PyPSA also builds no ramp row at the first snapshot of a later period, with or +without these flags. The ramp-limited coal unit in this rung raises its output +from 59.2 to 90 across the period boundary, above its limit of 10 per +snapshot, while its ramp rows bind inside each period. + +| PyPSA | status | note | +| --- | --- | --- | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance), per period | done | two more cases in the charge carried in: a `shift(…, edge='wrap', by=snapshot_period, within=period)` and the initial level at `position(snapshot, by=snapshot_period, within=period) == 0` | +| [`{c}-p-ramp_limit_*`, `-bigM`, at a period start](#generator-p-ramp_limit_up) | done | the `where:` drops every period start but the horizon's first | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7438.461538461539`, 212 rows. + +
+The network, as PyPSA code + +`rung_29_storage_per_period.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 29: storage per investment period — a storage unit and a store that cycle within each period, two that reopen on their initial level, and a ramp that restarts at a period start.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, four storages that each close or reopen per period, a ramp-limited coal unit.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] + n.snapshot_weightings['stores'] = [0.5, 2.0, 1.5, 2.5, 0.5, 2.0, 1.5, 2.5] + n.add('Bus', 'hub') + n.add('Generator', 'coal29', bus='hub', p_nom=100, marginal_cost=10, ramp_limit_up=0.1, ramp_limit_down=0.1) + n.add('Generator', 'peak29', bus='hub', p_nom=200, marginal_cost=[80, 20, 90, 30, 80, 20, 90, 30]) + n.add('StorageUnit', 'su_cycle', bus='hub', p_nom=15, max_hours=4, cyclic_state_of_charge_per_period=True) + n.add( + 'StorageUnit', + 'su_reset', + bus='hub', + p_nom=15, + max_hours=4, + state_of_charge_initial=20, + state_of_charge_initial_per_period=True, + ) + n.add('Store', 'e_cycle', bus='hub', e_nom=30, e_cyclic_per_period=True) + n.add('Store', 'e_reset', bus='hub', e_nom=30, e_initial=10, e_initial_per_period=True) + n.add('Load', 'hub_load', bus='hub', p_set=[40, 60, 70, 40, 90, 110, 120, 90]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2590,6 +2671,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | | $`\mathrm{soc}^{0}`$ | `StorageUnit_state_of_charge_initial` over $`\mathcal{S}`$ — charge held before the first snapshot | | $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | +| $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | @@ -2602,6 +2685,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\rho^{e}`$ | `Store_retention` over $`\mathcal{T} \times \mathcal{V}`$ — share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | | $`\mathrm{e}^{0}`$ | `Store_e_initial` over $`\mathcal{V}`$ — energy held before the first snapshot | | $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | +| $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | | $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | @@ -2731,8 +2816,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | -| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | -| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise | +| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise. Per period, the same holds with each investment period as the horizon | +| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise. Per period, the same holds with each investment period as the horizon | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | @@ -2749,8 +2834,12 @@ $`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. +$`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $`t \boxminus_{v}^{\mathrm{relation}(t)} k`$ is both at once. + $`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + ### Objective ```yaml @@ -3389,7 +3478,8 @@ Generator_p_ramp_limit_up_run_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_up_rate * Generator_p_nom_ext @@ -3397,7 +3487,7 @@ Generator_p_ramp_limit_up_run_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -3414,7 +3504,8 @@ Generator_p_ramp_limit_up_start_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_start_up_rate * Generator_p_nom_ext @@ -3422,7 +3513,7 @@ Generator_p_ramp_limit_up_start_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -3439,7 +3530,8 @@ Generator_p_ramp_limit_down_run_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_down_rate * Generator_p_nom_ext @@ -3447,7 +3539,7 @@ Generator_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -3464,7 +3556,8 @@ Generator_p_ramp_limit_down_shut_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_shut_down_rate * Generator_p_nom_ext @@ -3472,7 +3565,7 @@ Generator_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -3858,7 +3951,8 @@ Link_p_ramp_limit_up_run_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_p - Link_previous_p <= Link_ramp_up_rate * Link_p_nom_ext @@ -3866,7 +3960,7 @@ Link_p_ramp_limit_up_run_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-start-bigM` @@ -3883,7 +3977,8 @@ Link_p_ramp_limit_up_start_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_p - Link_previous_p <= Link_start_up_rate * Link_p_nom_ext @@ -3891,7 +3986,7 @@ Link_p_ramp_limit_up_start_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-run-bigM` @@ -3908,7 +4003,8 @@ Link_p_ramp_limit_down_run_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_previous_p - Link_p <= Link_ramp_down_rate * Link_p_nom_ext @@ -3916,7 +4012,7 @@ Link_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-shut-bigM` @@ -3933,7 +4029,8 @@ Link_p_ramp_limit_down_shut_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_previous_p - Link_p <= Link_shut_down_rate * Link_p_nom_ext @@ -3941,7 +4038,7 @@ Link_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p_nom_modularity` @@ -4327,7 +4424,8 @@ Process_p_ramp_limit_up_run_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_p - Process_previous_p <= Process_ramp_up_rate * Process_p_nom_ext @@ -4335,7 +4433,7 @@ Process_p_ramp_limit_up_run_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-start-bigM` @@ -4352,7 +4450,8 @@ Process_p_ramp_limit_up_start_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_p - Process_previous_p <= Process_start_up_rate * Process_p_nom_ext @@ -4360,7 +4459,7 @@ Process_p_ramp_limit_up_start_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-run-bigM` @@ -4377,7 +4476,8 @@ Process_p_ramp_limit_down_run_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_previous_p - Process_p <= Process_ramp_down_rate * Process_p_nom_ext @@ -4385,7 +4485,7 @@ Process_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-shut-bigM` @@ -4402,7 +4502,8 @@ Process_p_ramp_limit_down_shut_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_previous_p - Process_p <= Process_shut_down_rate * Process_p_nom_ext @@ -4410,7 +4511,7 @@ Process_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p_nom_modularity` @@ -5045,17 +5146,18 @@ Generator_p_ramp_limit_up: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the horizon running brought an unknown output, so it carries no row at the - first snapshot — nor does any unit a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -5069,17 +5171,18 @@ Generator_p_ramp_limit_down: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the horizon running brought an unknown output, so it carries no row at the - first snapshot — nor does any unit a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -5093,17 +5196,18 @@ Link_p_ramp_limit_up: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A link that came into the horizon running brought an unknown flow, so it carries no row at the - first snapshot — nor does any link a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -5117,17 +5221,18 @@ Link_p_ramp_limit_down: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A link that came into the horizon running brought an unknown flow, so it carries no row at the - first snapshot — nor does any link a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -5141,17 +5246,18 @@ Process_p_ramp_limit_up: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A process that came into the horizon running brought an unknown internal power, so it carries no row at the - first snapshot — nor does any process a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -5165,17 +5271,18 @@ Process_p_ramp_limit_down: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A process that came into the horizon running brought an unknown internal power, so it carries no row at the - first snapshot — nor does any process a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -6458,20 +6565,33 @@ StorageUnit_charge_carried_in: the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise + snapshot's less standing loss otherwise. Per period, the same holds with + each investment period as the horizon dims: [scenario, snapshot, storage_unit] cases: cyclic: - when: StorageUnit_cyclic_state_of_charge + when: StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period AND NOT StorageUnit_state_of_charge_initial_per_period expression: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap') opening: - when: not StorageUnit_cyclic_state_of_charge AND position(snapshot) == 0 + when: >- + NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period AND position(snapshot) == 0 + expression: StorageUnit_state_of_charge_initial + period_cyclic: + when: StorageUnit_cyclic_state_of_charge_per_period + expression: >- + StorageUnit_retention + * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: >- + StorageUnit_state_of_charge_initial_per_period AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND position(snapshot, by=snapshot_period, within=period) == 0 expression: StorageUnit_state_of_charge_initial otherwise: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1) ``` ```math -\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \mathrm{pos}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} +\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \mathrm{pos}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \mathrm{reset}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} ``` ### `Store_energy_carried_in` @@ -6482,20 +6602,27 @@ Store_energy_carried_in: the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise + snapshot's less standing loss otherwise. Per period, the same holds with + each investment period as the horizon dims: [scenario, snapshot, store] cases: cyclic: - when: Store_e_cyclic + when: Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap') opening: - when: not Store_e_cyclic AND position(snapshot) == 0 + when: NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period AND position(snapshot) == 0 + expression: Store_e_initial + period_cyclic: + when: Store_e_cyclic_per_period + expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: Store_e_initial_per_period AND NOT Store_e_cyclic_per_period AND position(snapshot, by=snapshot_period, within=period) == 0 expression: Store_e_initial otherwise: Store_retention * shift(Store_e, along=snapshot, offset=1) ``` ```math -\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,t \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \mathrm{pos}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} +\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,t \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \mathrm{pos}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \mathrm{reset}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} ``` ### `Link_output_arrival` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 289379ab..4f7efc54 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -580,6 +580,21 @@ parameters: description: whether the horizon closes on itself instead of opening on the initial charge dims: [storage_unit] dtype: bool + StorageUnit_cyclic_state_of_charge_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + charge on to the next; it overrides `cyclic_state_of_charge` and + `state_of_charge_initial_per_period`. PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [storage_unit] + dtype: bool + StorageUnit_state_of_charge_initial_per_period: + description: >- + whether each investment period opens on the initial charge instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [storage_unit] + dtype: bool StorageUnit_marginal_cost: description: cost of one unit of dispatch dims: [snapshot, storage_unit] @@ -618,6 +633,21 @@ parameters: description: whether the horizon closes on itself instead of opening on the initial energy dims: [store] dtype: bool + Store_e_cyclic_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. + PyPSA reads it only under `multi_investment_periods`, so data prep feeds + false otherwise + dims: [store] + dtype: bool + Store_e_initial_per_period: + description: >- + whether each investment period opens on the initial energy instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [store] + dtype: bool Store_marginal_cost: description: cost of one unit of power delivered dims: [snapshot, store] @@ -1448,14 +1478,27 @@ expressions: the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise + snapshot's less standing loss otherwise. Per period, the same holds with + each investment period as the horizon dims: [scenario, snapshot, storage_unit] cases: cyclic: - when: StorageUnit_cyclic_state_of_charge + when: StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period AND NOT StorageUnit_state_of_charge_initial_per_period expression: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap') opening: - when: not StorageUnit_cyclic_state_of_charge AND position(snapshot) == 0 + when: >- + NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period AND position(snapshot) == 0 + expression: StorageUnit_state_of_charge_initial + period_cyclic: + when: StorageUnit_cyclic_state_of_charge_per_period + expression: >- + StorageUnit_retention + * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: >- + StorageUnit_state_of_charge_initial_per_period AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND position(snapshot, by=snapshot_period, within=period) == 0 expression: StorageUnit_state_of_charge_initial otherwise: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1) Store_energy_carried_in: @@ -1463,14 +1506,21 @@ expressions: the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise + snapshot's less standing loss otherwise. Per period, the same holds with + each investment period as the horizon dims: [scenario, snapshot, store] cases: cyclic: - when: Store_e_cyclic + when: Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap') opening: - when: not Store_e_cyclic AND position(snapshot) == 0 + when: NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period AND position(snapshot) == 0 + expression: Store_e_initial + period_cyclic: + when: Store_e_cyclic_per_period + expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: Store_e_initial_per_period AND NOT Store_e_cyclic_per_period AND position(snapshot, by=snapshot_period, within=period) == 0 expression: Store_e_initial otherwise: Store_retention * shift(Store_e, along=snapshot, offset=1) Link_output_arrival: @@ -1779,7 +1829,8 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_ramp_up_rate * Generator_p_nom_ext @@ -1793,7 +1844,8 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_p - Generator_previous_p <= Generator_start_up_rate * Generator_p_nom_ext @@ -1807,7 +1859,8 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_ramp_down_rate * Generator_p_nom_ext @@ -1821,7 +1874,8 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND Generator_active expression: >- Generator_previous_p - Generator_p <= Generator_shut_down_rate * Generator_p_nom_ext @@ -1973,7 +2027,8 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_p - Link_previous_p <= Link_ramp_up_rate * Link_p_nom_ext @@ -1987,7 +2042,8 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_p - Link_previous_p <= Link_start_up_rate * Link_p_nom_ext @@ -2001,7 +2057,8 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_previous_p - Link_p <= Link_ramp_down_rate * Link_p_nom_ext @@ -2015,7 +2072,8 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Link_status_initial == 0) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND Link_active expression: >- Link_previous_p - Link_p <= Link_shut_down_rate * Link_p_nom_ext @@ -2167,7 +2225,8 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_p - Process_previous_p <= Process_ramp_up_rate * Process_p_nom_ext @@ -2181,7 +2240,8 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_p - Process_previous_p <= Process_start_up_rate * Process_p_nom_ext @@ -2195,7 +2255,8 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_previous_p - Process_p <= Process_ramp_down_rate * Process_p_nom_ext @@ -2209,7 +2270,8 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Process_status_initial == 0) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND Process_active expression: >- Process_previous_p - Process_p <= Process_shut_down_rate * Process_p_nom_ext @@ -2445,12 +2507,13 @@ constraints: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the horizon running brought an unknown output, so it carries no row at the - first snapshot — nor does any unit a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance Generator_p_ramp_limit_down: description: >- @@ -2458,12 +2521,13 @@ constraints: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the horizon running brought an unknown output, so it carries no row at the - first snapshot — nor does any unit a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Generator_committable AND Generator_status_initial == 0)) AND Generator_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Link_p_ramp_limit_up: description: >- @@ -2471,12 +2535,13 @@ constraints: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A link that came into the horizon running brought an unknown flow, so it carries no row at the - first snapshot — nor does any link a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance Link_p_ramp_limit_down: description: >- @@ -2484,12 +2549,13 @@ constraints: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A link that came into the horizon running brought an unknown flow, so it carries no row at the - first snapshot — nor does any link a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Link_committable AND Link_status_initial == 0)) AND Link_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance Process_p_ramp_limit_up: description: >- @@ -2497,12 +2563,13 @@ constraints: its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A process that came into the horizon running brought an unknown internal power, so it carries no row at the - first snapshot — nor does any process a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance Process_p_ramp_limit_down: description: >- @@ -2510,12 +2577,13 @@ constraints: its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A process that came into the horizon running brought an unknown internal power, so it carries no row at the - first snapshot — nor does any process a big M releases instead + first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot) > 0 OR (Process_committable AND Process_status_initial == 0)) AND Process_active + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance StorageUnit_ext_p_dispatch_lower: description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index c2d2677e..62204118 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1074,5 +1074,36 @@ "Process-start_up-p-fixed-upper": 4, "Process-status-p-fixed-upper": 4 } + }, + "rung_29_storage_per_period": { + "columns": { + "Generator-p": 16, + "StorageUnit-p_dispatch": 16, + "StorageUnit-p_store": 16, + "StorageUnit-state_of_charge": 16, + "Store-e": 16, + "Store-p": 16 + }, + "global_constraints": {}, + "objective": 7438.461538461539, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Generator-p-ramp_limit_down": 6, + "Generator-p-ramp_limit_up": 6, + "StorageUnit-energy_balance": 16, + "StorageUnit-fix-p_dispatch-lower": 16, + "StorageUnit-fix-p_dispatch-upper": 16, + "StorageUnit-fix-p_store-lower": 16, + "StorageUnit-fix-p_store-upper": 16, + "StorageUnit-fix-state_of_charge-lower": 16, + "StorageUnit-fix-state_of_charge-upper": 16, + "Store-energy_balance": 16, + "Store-fix-e-lower": 16, + "Store-fix-e-upper": 16 + } } } diff --git a/examples/references/pypsa/rung_29_storage_per_period.py b/examples/references/pypsa/rung_29_storage_per_period.py new file mode 100644 index 00000000..7177e8df --- /dev/null +++ b/examples/references/pypsa/rung_29_storage_per_period.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 29: storage per investment period — a storage unit and a store that cycle within each period, two that reopen on their initial level, and a ramp that restarts at a period start.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, four storages that each close or reopen per period, a ramp-limited coal unit.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] + n.snapshot_weightings['stores'] = [0.5, 2.0, 1.5, 2.5, 0.5, 2.0, 1.5, 2.5] + n.add('Bus', 'hub') + n.add('Generator', 'coal29', bus='hub', p_nom=100, marginal_cost=10, ramp_limit_up=0.1, ramp_limit_down=0.1) + n.add('Generator', 'peak29', bus='hub', p_nom=200, marginal_cost=[80, 20, 90, 30, 80, 20, 90, 30]) + n.add('StorageUnit', 'su_cycle', bus='hub', p_nom=15, max_hours=4, cyclic_state_of_charge_per_period=True) + n.add( + 'StorageUnit', + 'su_reset', + bus='hub', + p_nom=15, + max_hours=4, + state_of_charge_initial=20, + state_of_charge_initial_per_period=True, + ) + n.add('Store', 'e_cycle', bus='hub', e_nom=30, e_cyclic_per_period=True) + n.add('Store', 'e_reset', bus='hub', e_nom=30, e_initial=10, e_initial_per_period=True) + n.add('Load', 'hub_load', bus='hub', p_set=[40, 60, 70, 40, 90, 110, 120, 90]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index a8e2704e..6eafe555 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -246,6 +246,8 @@ names: StorageUnit_state_of_charge_initial: '\mathrm{soc}^{0}' StorageUnit_charge_carried_in: '\overleftarrow{\mathit{soc}}' StorageUnit_cyclic_state_of_charge: '\mathrm{cyc}' + StorageUnit_cyclic_state_of_charge_per_period: '\mathrm{cyc}^{y}' + StorageUnit_state_of_charge_initial_per_period: '\mathrm{reset}' StorageUnit_marginal_cost: '\mathrm{c}^{h}' StorageUnit_marginal_cost_storage: '\mathrm{c}^{\mathrm{soc}}' StorageUnit_spill_cost: '\mathrm{c}^{\mathrm{spill}}' @@ -261,6 +263,8 @@ names: Store_e_initial: '\mathrm{e}^{0}' Store_energy_carried_in: '\overleftarrow{e}' Store_e_cyclic: '\mathrm{cyc}^{e}' + Store_e_cyclic_per_period: '\mathrm{cyc}^{e,y}' + Store_e_initial_per_period: '\mathrm{reset}^{e}' Store_marginal_cost: '\mathrm{c}^{q}' Store_marginal_cost_storage: '\mathrm{c}^{e}' Store_e_set: '\mathrm{e}^{\mathrm{set}}' From 2a7d5e95b32cdebd4377b19ed19b46be3376e349 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 13:17:49 +0200 Subject: [PATCH 15/75] docs(pypsa): a security-constrained run limits every branch flow after any one listed outage --- docs/examples/pypsa.md | 315 +++++++++++++++++- examples/pypsa.yaml | 141 +++++++- examples/references/pypsa/reference.py | 11 +- examples/references/pypsa/references.json | 50 +++ .../pypsa/rung_30_security_constrained.py | 30 ++ examples/symbols/pypsa.yaml | 6 + 6 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 examples/references/pypsa/rung_30_security_constrained.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index d6c8d4e8..5788a998 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -24,8 +24,9 @@ A row is **done** once the file states it as the one block PyPSA builds. **split** means the same feasible region and optimum under a different statement, such as several `where:` blocks. **open** means not stated yet. **out** means never stated, deliberately: emitted only under the keyword, -scope or version the note names. A name carrying `{k}` or `{s}` stands for the -family PyPSA numbers per segment or scenario. +scope or version the note names. A name carrying `{k}`, `{s}`, `{c}` or `{n}` stands +for the family PyPSA numbers per segment, scenario, outaged component or +sub-network. Each rung's banner states what PyPSA solved its reference network to. @@ -2478,6 +2479,79 @@ def build(): +### Rung 30 — security-constrained + +`n.optimize.optimize_security_constrained(branch_outages=...)`: after any one +outage of a listed passive branch, every branch of the same sub-network carries +its flow within its rating. PyPSA computes the sub-network's branch outage +distribution factors (BODF) and copies each flow limit row with the outaged +branch's flow, times its factor, added to the left-hand side +(`abstract.py:443-489`). The copy keeps the row's sense, right-hand side and +loss term, and its extendable rating. An outage is a line or a transformer, and +a plain list names lines. The outaged branch is monitored too, at the factor +`-1`. The file states the copies over an `outage` axis, with the factors as +data prep, `Line_BODF` and `Transformer_BODF`. A plain run supplies no outage, +so no copy is built and the model collapses to the standard one. + +The rung outages two lines and a transformer of a meshed triangle and leaves +the third line monitored only. A plain `n.optimize()` solves the same network +at objective `17380.0`; the outages raise it to `22113.33` (#620). The cheap +unit at `a` falls to 32 in every snapshot, the unit at `b` covers the rest, and +the extendable line `ca` builds 20.7 instead of 2. Six of the 120 copied rows +bind, in `Transformer-fix-s-lower` against a line outage and in +`Line-ext-s-lower` against a transformer outage. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Line-fix-s-*-security-for-{c}-outage-in-sub-network-{n}`](#line-fix-s-lower-security-for-c-outage-in-sub-network-n), [`Line-ext-s-*-security-…`](#line-ext-s-lower-security-for-c-outage-in-sub-network-n) | split | PyPSA names a row per outaged component and sub-network; one block over the `outage` axis | +| [`Transformer-fix-s-*-security-…`](#transformer-fix-s-lower-security-for-c-outage-in-sub-network-n), [`Transformer-ext-s-*-security-…`](#transformer-ext-s-lower-security-for-c-outage-in-sub-network-n) | split | the same for a transformer | +| a branch not active in a period | done | PyPSA keeps the copy with that branch's flow dropped, so the file reads its flow as zero there; a copy left with no variable is not built here, where linopy counts it; no rung records it | +| a security-constrained run over scenarios | out | PyPSA `1.3.0` raises, see [Refusals](#refusals) | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `22113.333333333332`, 240 rows. + +
+The network, as PyPSA code + +`rung_30_security_constrained.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 30: security-constrained — a meshed triangle of lines and two transformers, one extendable, that must carry their flow within their rating after any one of three outages.""" + +from __future__ import annotations + +import pandas as pd +import spine + +BRANCH_OUTAGES = pd.MultiIndex.from_tuples([('Line', 'ab'), ('Line', 'ca'), ('Transformer', 'ca_t')]) + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'a') + n.add('Bus', 'b') + n.add('Bus', 'c') + n.add('Generator', 'hydro30', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel30', bus='b', p_nom=100, marginal_cost=50) + n.add('Generator', 'peak30', bus='c', p_nom=100, marginal_cost=200) + n.add('Load', 'town30', bus='c', p_set=[40, 60, 80, 50]) + n.add('Line', 'ab', bus0='a', bus1='b', x=0.1, s_nom=60) + n.add('Line', 'bc', bus0='b', bus1='c', x=0.1, s_nom=60, s_max_pu=0.9) + n.add('Line', 'ca', bus0='c', bus1='a', x=0.1, s_nom_extendable=True, capital_cost=5, s_nom_max=200) + n.add('Transformer', 'ca_t', bus0='c', bus1='a', x=0.2, s_nom=30) + n.add('Transformer', 'bc_t', bus0='b', bus1='c', x=0.3, s_nom_extendable=True, capital_cost=3, s_nom_max=50) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2493,6 +2567,7 @@ data prep, or harness — is one open question. Line numbers are pinned pypsa | `NotImplementedError`, `global_constraints.py:457` | depletion with period weightings `!= 1` | out | | | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | +| `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | Duals and solutions are read back by the harness on the lpspec side: `marginal_price` is the balance dual over `w_objective`, `mu_upper` the @@ -2501,7 +2576,7 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. Which snapshots an asset is active in, and a scenario's weight, are data prep. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. #### Sets @@ -2518,9 +2593,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | | $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | -| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$ — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | +| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | | $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | @@ -2699,6 +2775,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | | $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | | $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | +| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run | | $`\overline{\ell}`$ | `Line_loss_max` over $`\mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | | $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | @@ -2712,6 +2789,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | | $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | | $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row | +| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | | $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | | $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | @@ -2827,6 +2905,9 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. PyPSA sums the components that carry a carrier attribute; the transformer term is the spec's own extension, since PyPSA gives a transformer no carrier | +| $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | +| $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | +| $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | Upright is what the model is given — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -5110,6 +5191,180 @@ Transformer_loss_tangents_reverse: \ell^{\sigma}_{\xi,t,m} - \mathrm{a}^{\sigma}_{t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} ``` +### `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + +`Line_fix_s_lower_security` + +```yaml +Line_fix_s_lower_security: + description: >- + `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at least the negative + of its rating: its flow takes on its share of the outaged branch's + flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom +``` + +```math +\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +### `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + +`Line_fix_s_upper_security` + +```yaml +Line_fix_s_upper_security: + description: >- + `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at most its rating: its + flow takes on its share of the outaged branch's flow. PyPSA names + one row per outaged component `c` and sub-network `n`; this block + states them all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom +``` + +```math +\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +### `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + +`Line_ext_s_lower_security` + +```yaml +Line_ext_s_lower_security: + description: >- + `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at least the + negative of its rating of the chosen build: its flow takes on its + share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext +``` + +```math +\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +### `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + +`Line_ext_s_upper_security` + +```yaml +Line_ext_s_upper_security: + description: >- + `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at most its rating + of the chosen build: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext +``` + +```math +\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +### `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + +`Transformer_fix_s_lower_security` + +```yaml +Transformer_fix_s_lower_security: + description: >- + `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at least the + negative of its rating: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom +``` + +```math +\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +### `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + +`Transformer_fix_s_upper_security` + +```yaml +Transformer_fix_s_upper_security: + description: >- + `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at most its + rating: its flow takes on its share of the outaged branch's flow. + PyPSA names one row per outaged component `c` and sub-network `n`; + this block states them all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom +``` + +```math +\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +### `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + +`Transformer_ext_s_lower_security` + +```yaml +Transformer_ext_s_lower_security: + description: >- + `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at least + the negative of its rating of the chosen build: its flow takes on + its share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext +``` + +```math +\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +### `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + +`Transformer_ext_s_upper_security` + +```yaml +Transformer_ext_s_upper_security: + description: >- + `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at most + its rating of the chosen build: its flow takes on its share of the + outaged branch's flow. PyPSA names one row per outaged component + `c` and sub-network `n`; this block states them all over the + outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext +``` + +```math +\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + ### `Kirchhoff-Voltage-Law` `Kirchhoff_Voltage_Law` @@ -6806,6 +7061,56 @@ Carrier_additions: \mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_carrier}(m) = i} \Sigma_{m} \cdot \mathrm{new}^{\sigma}_{y,m} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} ``` +### `Line_s_monitored` + +```yaml +Line_s_monitored: + description: >- + the flow a line's post-contingency rows read — its flow where it stands, + nothing where it does not, since PyPSA builds those rows for every + branch of the sub-network in every snapshot + dims: [scenario, snapshot, line] + cases: + standing: { when: Line_active, expression: Line_s } + otherwise: 0 +``` + +```math +\check{s}_{\xi,t,k} = \begin{cases} s_{\xi,t,k} & \text{if } \mathrm{on}^{s}_{t,k} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} +``` + +### `Transformer_s_monitored` + +```yaml +Transformer_s_monitored: + description: the flow a transformer's post-contingency rows read, as a line's + dims: [scenario, snapshot, transformer] + cases: + standing: { when: Transformer_active, expression: Transformer_s } + otherwise: 0 +``` + +```math +\check{\sigma}_{\xi,t,m} = \begin{cases} \sigma_{\xi,t,m} & \text{if } \mathrm{on}^{\sigma}_{t,m} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} +``` + +### `Outage_s` + +```yaml +Outage_s: + description: >- + the flow an outage takes off its branch — the outaged line's or + transformer's flow before it goes out + dims: [scenario, snapshot, outage] + cases: + line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } + otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) +``` + +```math +\hat{s}_{\xi,t,\kappa} = \begin{cases} \check{s}_{\xi,t,\mathrm{Outage\_line}(\kappa)} & \text{if } \mathrm{Outage\_line}(\kappa) \text{ is defined} \\ \check{\sigma}_{\xi,t,\mathrm{Outage\_transformer}(\kappa)} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ \kappa \in \mathcal{K}^{\mathrm{out}} +``` + #### Variable domains **`Generator_p`** diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 4f7efc54..9b0d747b 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -10,8 +10,10 @@ description: >- paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the - model collapses to the standard one. Which snapshots an asset is active in, - and a scenario's weight, are data prep. + model collapses to the standard one. A security-constrained run copies each + branch flow limit once per outage in an `outage` set that a plain run leaves + empty. Which snapshots an asset is active in, a scenario's weight, and the + outage factors are data prep. dimensions: scenario: @@ -49,6 +51,11 @@ dimensions: description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised cycle: description: independent cycles of the passive network graph — the cycle basis, data prep + outage: + description: >- + the passive branches a security-constrained run takes out one at a time + — PyPSA's `branch_outages`, each a line or a transformer; none on a + plain run segment: description: >- the cuts a passive branch's loss curve is held above — PyPSA's tangents, @@ -687,6 +694,13 @@ parameters: the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row dims: [line, cycle] + Line_BODF: + description: >- + the share of an outaged branch's flow a line takes on when that branch + goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row + only where the line and the outage share a sub-network, -1 at the + outaged line itself + dims: [line, outage] transmission_losses: description: >- whether the network dissipates transmission losses — PyPSA's @@ -740,6 +754,12 @@ parameters: signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row dims: [transformer, cycle] + Transformer_BODF: + description: >- + the share of an outaged branch's flow a transformer takes on when that + branch goes out, as a line's; a row only where the transformer and the + outage share a sub-network + dims: [transformer, outage] Transformer_phase_shift_weight: description: >- a fixed transformer's phase shift in radians, signed by its orientation @@ -956,6 +976,14 @@ relations: description: the bus at a transformer's other end key: transformer values: bus + Outage_line: + description: the line an outage takes out; an outage of a transformer has no row + key: outage + values: line + Outage_transformer: + description: the transformer an outage takes out; an outage of a line has no row + key: outage + values: transformer variables: Generator_p: @@ -1623,6 +1651,29 @@ expressions: + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + sum(Transformer_s_nom_ext * Transformer_first_active, by=Transformer_carrier, over=transformer, into=carrier) + Line_s_monitored: + description: >- + the flow a line's post-contingency rows read — its flow where it stands, + nothing where it does not, since PyPSA builds those rows for every + branch of the sub-network in every snapshot + dims: [scenario, snapshot, line] + cases: + standing: { when: Line_active, expression: Line_s } + otherwise: 0 + Transformer_s_monitored: + description: the flow a transformer's post-contingency rows read, as a line's + dims: [scenario, snapshot, transformer] + cases: + standing: { when: Transformer_active, expression: Transformer_s } + otherwise: 0 + Outage_s: + description: >- + the flow an outage takes off its branch — the outaged line's or + transformer's flow before it goes out + dims: [scenario, snapshot, outage] + cases: + line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } + otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) constraints: Generator_fix_p_lower: @@ -2487,6 +2538,92 @@ constraints: dims: [scenario, snapshot, transformer, segment] where: transmission_losses AND Transformer_active expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + Line_fix_s_lower_security: + description: >- + `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at least the negative + of its rating: its flow takes on its share of the outaged branch's + flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom + Line_fix_s_upper_security: + description: >- + `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at most its rating: its + flow takes on its share of the outaged branch's flow. PyPSA names + one row per outaged component `c` and sub-network `n`; this block + states them all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom + Line_ext_s_lower_security: + description: >- + `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at least the + negative of its rating of the chosen build: its flow takes on its + share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext + Line_ext_s_upper_security: + description: >- + `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at most its rating + of the chosen build: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext + Transformer_fix_s_lower_security: + description: >- + `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at least the + negative of its rating: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper_security: + description: >- + `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at most its + rating: its flow takes on its share of the outaged branch's flow. + PyPSA names one row per outaged component `c` and sub-network `n`; + this block states them all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower_security: + description: >- + `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at least + the negative of its rating of the chosen build: its flow takes on + its share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper_security: + description: >- + `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at most + its rating of the chosen build: its flow takes on its share of the + outaged branch's flow. PyPSA names one row per outaged component + `c` and sub-network `n`; this block states them all over the + outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the diff --git a/examples/references/pypsa/reference.py b/examples/references/pypsa/reference.py index 0efedf47..0c70a779 100644 --- a/examples/references/pypsa/reference.py +++ b/examples/references/pypsa/reference.py @@ -16,7 +16,8 @@ the spine (`spine.py`) plus that rung's own `n.add` calls, data inline, so the PyPSA model under review is the script itself. A rung stated by a file of its own names it as `MODEL`; one that needs `n.optimize` keywords names them -as `OPTIMIZE`. PyPSA is not a dependency +as `OPTIMIZE`; one that names `BRANCH_OUTAGES` is solved by +`n.optimize.optimize_security_constrained` over them. PyPSA is not a dependency of this project; this script pins the versions the recorded numbers are from, and the `PyPSA references` workflow runs `--check` on every change. """ @@ -67,7 +68,13 @@ def record(n: pypsa.Network) -> dict[str, object]: def solved(rung: str) -> dict[str, object]: n = build(rung) - status, condition = n.optimize(solver_name='highs', **keywords(rung)) + outages = getattr(importlib.import_module(rung), 'BRANCH_OUTAGES', None) + if outages is None: + status, condition = n.optimize(solver_name='highs', **keywords(rung)) + else: + status, condition = n.optimize.optimize_security_constrained( + solver_name='highs', branch_outages=outages, **keywords(rung) + ) assert status == 'ok', f'{rung}: HiGHS did not solve — {status} / {condition}' return record(n) diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 62204118..62698829 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1105,5 +1105,55 @@ "Store-fix-e-lower": 16, "Store-fix-e-upper": 16 } + }, + "rung_30_security_constrained": { + "columns": { + "Generator-p": 20, + "Line-s": 12, + "Line-s_nom": 1, + "Link-p": 4, + "Transformer-s": 8, + "Transformer-s_nom": 1 + }, + "global_constraints": {}, + "objective": 22113.333333333332, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 20, + "Generator-fix-p-upper": 20, + "Kirchhoff-Voltage-Law": 8, + "Line-ext-s-lower": 4, + "Line-ext-s-lower-security-for-Line-outage-in-sub-network-2": 8, + "Line-ext-s-lower-security-for-Transformer-outage-in-sub-network-2": 4, + "Line-ext-s-upper": 4, + "Line-ext-s-upper-security-for-Line-outage-in-sub-network-2": 8, + "Line-ext-s-upper-security-for-Transformer-outage-in-sub-network-2": 4, + "Line-ext-s_nom-lower": 1, + "Line-ext-s_nom-upper": 1, + "Line-fix-s-lower": 8, + "Line-fix-s-lower-security-for-Line-outage-in-sub-network-2": 16, + "Line-fix-s-lower-security-for-Transformer-outage-in-sub-network-2": 8, + "Line-fix-s-upper": 8, + "Line-fix-s-upper-security-for-Line-outage-in-sub-network-2": 16, + "Line-fix-s-upper-security-for-Transformer-outage-in-sub-network-2": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Transformer-ext-s-lower": 4, + "Transformer-ext-s-lower-security-for-Line-outage-in-sub-network-2": 8, + "Transformer-ext-s-lower-security-for-Transformer-outage-in-sub-network-2": 4, + "Transformer-ext-s-upper": 4, + "Transformer-ext-s-upper-security-for-Line-outage-in-sub-network-2": 8, + "Transformer-ext-s-upper-security-for-Transformer-outage-in-sub-network-2": 4, + "Transformer-ext-s_nom-lower": 1, + "Transformer-ext-s_nom-upper": 1, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-lower-security-for-Line-outage-in-sub-network-2": 8, + "Transformer-fix-s-lower-security-for-Transformer-outage-in-sub-network-2": 4, + "Transformer-fix-s-upper": 4, + "Transformer-fix-s-upper-security-for-Line-outage-in-sub-network-2": 8, + "Transformer-fix-s-upper-security-for-Transformer-outage-in-sub-network-2": 4 + } } } diff --git a/examples/references/pypsa/rung_30_security_constrained.py b/examples/references/pypsa/rung_30_security_constrained.py new file mode 100644 index 00000000..31a434e5 --- /dev/null +++ b/examples/references/pypsa/rung_30_security_constrained.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 30: security-constrained — a meshed triangle of lines and two transformers, one extendable, that must carry their flow within their rating after any one of three outages.""" + +from __future__ import annotations + +import pandas as pd +import spine + +BRANCH_OUTAGES = pd.MultiIndex.from_tuples([('Line', 'ab'), ('Line', 'ca'), ('Transformer', 'ca_t')]) + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'a') + n.add('Bus', 'b') + n.add('Bus', 'c') + n.add('Generator', 'hydro30', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel30', bus='b', p_nom=100, marginal_cost=50) + n.add('Generator', 'peak30', bus='c', p_nom=100, marginal_cost=200) + n.add('Load', 'town30', bus='c', p_set=[40, 60, 80, 50]) + n.add('Line', 'ab', bus0='a', bus1='b', x=0.1, s_nom=60) + n.add('Line', 'bc', bus0='b', bus1='c', x=0.1, s_nom=60, s_max_pu=0.9) + n.add('Line', 'ca', bus0='c', bus1='a', x=0.1, s_nom_extendable=True, capital_cost=5, s_nom_max=200) + n.add('Transformer', 'ca_t', bus0='c', bus1='a', x=0.2, s_nom=30) + n.add('Transformer', 'bc_t', bus0='b', bus1='c', x=0.3, s_nom_extendable=True, capital_cost=3, s_nom_max=50) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 6eafe555..2f2eb235 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -19,6 +19,7 @@ dimensions: process: { index: j, set: '\mathcal{J}' } process_output: { index: r, set: '\mathcal{R}' } cycle: { index: c, set: '\mathcal{C}' } + outage: { index: '\kappa', set: '\mathcal{K}^{\mathrm{out}}' } scenario: { index: '\xi', set: '\Xi' } period: { index: y, set: '\mathcal{Y}' } carrier: { index: i, set: '\mathcal{I}' } @@ -210,6 +211,11 @@ names: Transformer_loss_max: '\overline{\ell}^{\sigma}' Transformer_loss_slope: '\mathrm{a}^{\sigma}' Transformer_loss_offset: '\mathrm{b}^{\sigma}' + Line_BODF: '\beta' + Transformer_BODF: '\beta^{\sigma}' + Line_s_monitored: '\check{s}' + Transformer_s_monitored: '\check{\sigma}' + Outage_s: '\hat{s}' GlobalConstraint_type: '\mathrm{type}' GlobalConstraint_sense: '\mathrm{sense}' GlobalConstraint_constant: '\mathrm{K}' From 51096860a2139bc91c5b9c56e892033e56b127ce Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 13:03:54 +0200 Subject: [PATCH 16/75] docs(pypsa): an mga flag caps the system cost at a budget and minimises weighted builds --- docs/examples/pypsa.md | 184 +++++++++++++++++++--- examples/pypsa.yaml | 104 ++++++++++-- examples/references/pypsa/reference.py | 13 +- examples/references/pypsa/references.json | 23 +++ examples/references/pypsa/rung_31_mga.py | 30 ++++ examples/symbols/pypsa.yaml | 9 ++ tests/test_pypsa_collapses.py | 19 ++- 7 files changed, 336 insertions(+), 46 deletions(-) create mode 100644 examples/references/pypsa/rung_31_mga.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5788a998..580bb1a5 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -86,8 +86,8 @@ def build(): | [`Link-fix-p-upper`](#link-fix-p-upper) | done | | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | -| [`marginal_cost`](#objective) | done | | -| [`marginal_cost_quadratic`](#objective) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | +| [`marginal_cost`](#system_cost) | done | | +| [`marginal_cost_quadratic`](#system_cost) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | | `objective_constant` | split | an objective shift, compared net of `n._objective_constant` — rungs 11 and 13 carry a nonzero one, `21915277.52` and `160.0`, so the netting is under test | @@ -129,7 +129,7 @@ def build(): | [`StorageUnit-energy_balance`](#storageunit-energy_balance) | done | the charge carried into a snapshot is a cased quantity — cyclic, opening, carried; `(1-loss)**eh` is prep | | [`Store-energy_balance`](#store-energy_balance) | done | same | | [`StorageUnit-p_set`](#storageunit-p_set), [`{c}-{attr}_set`](#generator-p_set) | done | `Generator-p_set`, `Link-p_set`, `StorageUnit-state_of_charge_set`, `Store-e_set`, `Line-s_set` | -| [`marginal_cost_storage`, `spill_cost`](#objective) | done | | +| [`marginal_cost_storage`, `spill_cost`](#system_cost) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `4456.659315422356`, 103 rows. @@ -203,7 +203,7 @@ def build(): | [`{c}-ext-p_nom-lower/upper`](#generator-ext-p_nom-lower) | done | | | [`{c}-p_nom_set`](#generator-p_nom_set) | done | | | [`Generator-e_sum_min/max`](#generator-e_sum_min) | done | | -| [capital cost](#objective) | done | `periodized_cost` is an annuity, data prep | +| [capital cost](#system_cost) | done | `periodized_cost` is an annuity, data prep | > ✔ `pypsa 1.3.0` solves this rung's network at objective `7633.908502024292`, 184 rows. @@ -604,7 +604,7 @@ def build(): | [`{c}-com-up-time`, `-down-time`](#generator-com-up-time) | done | `sum_back(window=min_up_time)` | | [`{c}-com-status-min_up_time_must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | | [`{c}-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | the same prep mask over the down time brought in, status zero; PyPSA's name says `_must_stay_up`; rung 24 records it | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | | [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rung 12, a file of its own | @@ -823,7 +823,7 @@ feeds zero, so the term vanishes and the objective stays linear. | PyPSA | status | note | | --- | --- | --- | -| [`marginal_cost_quadratic`](#objective) | done | degree 2 in the objective; Generator and Link here | +| [`marginal_cost_quadratic`](#system_cost) | done | degree 2 in the objective; Generator and Link here | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12587.437500000098`, 60 rows. @@ -1304,7 +1304,7 @@ excess per scenario and the tail's average, blended into the objective at | [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | | [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | | [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | -| [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | +| [objective](#system_cost) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. @@ -1357,7 +1357,7 @@ own dimensions. | [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | -| [objective](#objective) | done | period weight on operation; capacity once per period it stands in | +| [objective](#system_cost) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | @@ -1548,7 +1548,7 @@ generator's and the link's, read over a converter. | [`Process-p_set`](#process-p_set) | done | a fixed internal power schedule | | [`Process-p_nom_set`](#process-p_nom_set) | done | a fixed built capacity | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | each port enters at its `rate` | -| [objective](#objective) | done | marginal cost on internal power; capital on capacity | +| [objective](#system_cost) | done | marginal cost on internal power; capital on capacity | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9730.0`, 70 rows. @@ -1624,7 +1624,7 @@ transformer's built capacity. | [`Transformer-s_nom_set`](#transformer-s_nom_set) | done | a fixed built capacity | | [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) | done | rung 6, over `x_pu_eff` and a phase shift | | [`Transformer-phase_shift`](#variable-domains) | done | rung 20, an optimised phase shift | -| [objective](#objective) | done | capital on capacity | +| [objective](#system_cost) | done | capital on capacity | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12274.401472395122`, 106 rows. @@ -2047,7 +2047,7 @@ extendable, modular, or both. | [`Link-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#link-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | | [`Link-p-ramp_limit_*`, `-*-bigM`](#link-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over flow | | [`Link-p_nom_modularity`](#link-p_nom_modularity) | done | | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `14013.0`, 235 rows. @@ -2169,7 +2169,7 @@ time rules bind. | [`Process-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#process-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | | [`Process-p-ramp_limit_*`, `-*-bigM`](#process-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over internal power | | [`Process-p_nom_modularity`](#process-p_nom_modularity) | done | | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `15956.125`, 235 rows. @@ -2552,6 +2552,77 @@ def build(): +### Rung 31 — MGA + +`optimize_mga` needs a solved network. It builds the model again, adds one +`budget` row, and replaces the objective (`mga.py:343`, `:350`, `:372`). The row +caps the model's objective plus `installed_capex` at `(1 + slack)` times +`capex + opex` of the optimum (`mga.py:256`, `:270`). Net of the objective +constant, that is `system_cost <= mga_budget`, and data prep computes the +budget from the optimum. The slack also scales the capital cost of fixed +assets, which the objective does not carry. In this rung, the 45 of fixed +hydro capex adds 4.5 to the cap. The new objective is the weights times the +variables they name, times `-1` to maximise (`mga.py:187`, `:372`). +`optimize_mga_in_direction` builds the same row and minimises the negated sum +of direction times dimension weights (`mga.py:508`, `:513`). Data prep folds +the sense and the direction into `{c}_mga_weight`, so one `goal` case covers +both. + +This rung maximises the wind build at a slack of 0.1. The optimum costs +`9884.17` and builds 116.67 of wind. The MGA run builds 174.85, and the +`budget` row holds with equality at dual `-0.05`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`budget`](#budget) | done | the `mga` flag builds the row; `mga_budget` is `(1 + slack) * optimal_cost - fixed_cost` plus the objective constant, data prep. The multi-period and stochastic budgets are PyPSA's `statistics` sums, data prep, and no rung records them | +| [MGA objective](#goal) | done | `goal` is the weighted builds where `mga` holds and `system_cost` elsewhere; a plain run is the standard objective | +| `weights` on a variable other than a build | open | PyPSA takes any `{c}-{attr}`, for example `Generator-p` per snapshot; each is one more parameter and one more term of the same shape | +| `optimize_mga_in_multiple_directions`, `project_solved` | out | a loop of `optimize_mga_in_direction` runs, and a read-back of their solutions | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `-174.8541663000001`, 63 rows. + +
+The network, as PyPSA code + +`rung_31_mga.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 31: MGA — the most wind a network builds within a tenth above its least cost.""" + +from __future__ import annotations + +import spine + +MGA = {'weights': {'Generator': {'p_nom': {'wind31': 1}}}, 'sense': 'max', 'slack': 0.1} + + +def build(): + """The spine plus a bus that extendable wind and gas and a fixed hydro unit with a capital cost serve; the wind carries a build already, so the objective has a constant.""" + n = spine.build() + n.add('Bus', 'mga31') + n.add( + 'Generator', + 'wind31', + bus='mga31', + p_nom=10, + p_nom_extendable=True, + capital_cost=20, + p_max_pu=[0.3, 0.9, 0.5, 0.6], + ) + n.add('Generator', 'gas31', bus='mga31', p_nom_extendable=True, capital_cost=8, marginal_cost=40) + n.add('Generator', 'hydro31', bus='mga31', p_nom=15, capital_cost=3, marginal_cost=5) + n.add('Load', 'mga31_load', bus='mga31', p_set=[50, 80, 60, 70]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2576,7 +2647,7 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. Under the `mga` flag the file is PyPSA's `optimize_mga`: the system cost is capped at a budget above its optimum, and weighted builds are the objective instead. #### Sets @@ -2690,6 +2761,15 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | | $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | +| $`\mathrm{mga}`$ | `mga` (scalar) — whether the run is PyPSA's `optimize_mga` or `optimize_mga_in_direction` — the system cost is capped by the `budget` row and the weighted builds are the objective; false is a plain `n.optimize()` | +| $`\overline{\mathrm{C}}`$ | `mga_budget` (scalar) — the most the system cost may reach under MGA — PyPSA's `(1 + slack) * optimal_cost - fixed_cost`, from the solved optimum's statistics, plus the objective constant this file leaves out of the cost; data prep | +| $`\beta`$ | `Generator_mga_weight` over $`\mathcal{G}`$ — what one unit of a generator's build weighs in the MGA objective — PyPSA's `weights['Generator']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{f}`$ | `Link_mga_weight` over $`\mathcal{L}`$ — what one unit of a link's build weighs in the MGA objective — PyPSA's `weights['Link']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{h}`$ | `StorageUnit_mga_weight` over $`\mathcal{S}`$ — what one unit of a storage unit's build weighs in the MGA objective — PyPSA's `weights['StorageUnit']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{e}`$ | `Store_mga_weight` over $`\mathcal{V}`$ — what one unit of a store's build weighs in the MGA objective — PyPSA's `weights['Store']['e_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{s}`$ | `Line_mga_weight` over $`\mathcal{K}`$ — what one unit of a line's build weighs in the MGA objective — PyPSA's `weights['Line']['s_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{z}`$ | `Process_mga_weight` over $`\mathcal{J}`$ — what one unit of a process's build weighs in the MGA objective — PyPSA's `weights['Process']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | +| $`\beta^{\sigma}`$ | `Transformer_mga_weight` over $`\mathcal{M}`$ — what one unit of a transformer's build weighs in the MGA objective — PyPSA's `weights['Transformer']['s_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | | $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | @@ -2908,6 +2988,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | +| $`\mathit{system\_cost}`$ | `system_cost` (scalar) — what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail | +| $`\mathit{goal}`$ | `goal` (scalar) — what the run minimises — the system cost, or under MGA the weighted builds within the budget | Upright is what the model is given — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -2926,21 +3008,12 @@ $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation pu ```yaml objective: sense: minimize - description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR + description: the system cost; under MGA the weighted builds instead + expression: goal ``` ```math -\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +\min \mathit{goal} ``` ### `Generator-fix-p-lower` @@ -6269,6 +6342,22 @@ CVaR_def: \theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR ``` +### `budget` + +`budget` + +```yaml +budget: + description: "`budget` — under MGA, the system cost stays within the slack above its optimum" + dims: [] + where: mga + expression: system_cost <= mga_budget +``` + +```math +\mathit{system\_cost} \le \overline{\mathrm{C}} \qquad \text{where } \mathrm{mga} +``` + ### `Generator_previous_status` ```yaml @@ -7111,6 +7200,51 @@ Outage_s: \hat{s}_{\xi,t,\kappa} = \begin{cases} \check{s}_{\xi,t,\mathrm{Outage\_line}(\kappa)} & \text{if } \mathrm{Outage\_line}(\kappa) \text{ is defined} \\ \check{\sigma}_{\xi,t,\mathrm{Outage\_transformer}(\kappa)} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ \kappa \in \mathcal{K}^{\mathrm{out}} ``` +### `system_cost` + +```yaml +system_cost: + description: what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR +``` + +```math +\mathit{system\_cost} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +``` + +### `goal` + +```yaml +goal: + description: what the run minimises — the system cost, or under MGA the weighted builds within the budget + dims: [] + cases: + mga: + when: mga + expression: >- + sum(Generator_p_nom_ext * Generator_mga_weight) + + sum(Link_p_nom_ext * Link_mga_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_mga_weight) + + sum(Store_e_nom_ext * Store_mga_weight) + + sum(Line_s_nom_ext * Line_mga_weight) + + sum(Process_p_nom_ext * Process_mga_weight) + + sum(Transformer_s_nom_ext * Transformer_mga_weight) + otherwise: system_cost +``` + +```math +\mathit{goal} = \begin{cases} \sum_{g \in \mathcal{G}} P_{g} \cdot \beta_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \beta^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \beta^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \beta^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \beta^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \beta^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \beta^{\sigma}_{m} & \text{if } \mathrm{mga} \\ \mathit{system\_cost} & \text{otherwise} \end{cases} +``` + #### Variable domains **`Generator_p`** diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 9b0d747b..3d853972 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -13,7 +13,9 @@ description: >- model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the - outage factors are data prep. + outage factors are data prep. Under the `mga` flag the file is PyPSA's + `optimize_mga`: the system cost is capped at a budget above its optimum, and + weighted builds are the objective instead. dimensions: scenario: @@ -407,6 +409,61 @@ parameters: CVaR_inv_tail: description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor dims: [] + mga: + description: >- + whether the run is PyPSA's `optimize_mga` or `optimize_mga_in_direction` + — the system cost is capped by the `budget` row and the weighted builds + are the objective; false is a plain `n.optimize()` + dims: [] + dtype: bool + mga_budget: + description: >- + the most the system cost may reach under MGA — PyPSA's `(1 + slack) * + optimal_cost - fixed_cost`, from the solved optimum's statistics, plus + the objective constant this file leaves out of the cost; data prep + dims: [] + Generator_mga_weight: + description: >- + what one unit of a generator's build weighs in the MGA objective — PyPSA's + `weights['Generator']['p_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [generator] + Link_mga_weight: + description: >- + what one unit of a link's build weighs in the MGA objective — PyPSA's + `weights['Link']['p_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [link] + StorageUnit_mga_weight: + description: >- + what one unit of a storage unit's build weighs in the MGA objective — PyPSA's + `weights['StorageUnit']['p_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [storage_unit] + Store_mga_weight: + description: >- + what one unit of a store's build weighs in the MGA objective — PyPSA's + `weights['Store']['e_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [store] + Line_mga_weight: + description: >- + what one unit of a line's build weighs in the MGA objective — PyPSA's + `weights['Line']['s_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [line] + Process_mga_weight: + description: >- + what one unit of a process's build weighs in the MGA objective — PyPSA's + `weights['Process']['p_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [process] + Transformer_mga_weight: + description: >- + what one unit of a transformer's build weighs in the MGA objective — PyPSA's + `weights['Transformer']['s_nom']`, negated to maximise; a direction's weighted + dimensions fold into it, data prep + dims: [transformer] period_weight_objective: description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs dims: [period] @@ -1674,6 +1731,33 @@ expressions: cases: line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) + system_cost: + description: what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR + goal: + description: what the run minimises — the system cost, or under MGA the weighted builds within the budget + dims: [] + cases: + mga: + when: mga + expression: >- + sum(Generator_p_nom_ext * Generator_mga_weight) + + sum(Link_p_nom_ext * Link_mga_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_mga_weight) + + sum(Store_e_nom_ext * Store_mga_weight) + + sum(Line_s_nom_ext * Line_mga_weight) + + sum(Process_p_nom_ext * Process_mga_weight) + + sum(Transformer_s_nom_ext * Transformer_mga_weight) + otherwise: system_cost constraints: Generator_fix_p_lower: @@ -2978,17 +3062,13 @@ constraints: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + budget: + description: "`budget` — under MGA, the system cost stays within the slack above its optimum" + dims: [] + where: mga + expression: system_cost <= mga_budget objective: sense: minimize - description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR + description: the system cost; under MGA the weighted builds instead + expression: goal diff --git a/examples/references/pypsa/reference.py b/examples/references/pypsa/reference.py index 0c70a779..b8df1f91 100644 --- a/examples/references/pypsa/reference.py +++ b/examples/references/pypsa/reference.py @@ -17,7 +17,9 @@ the PyPSA model under review is the script itself. A rung stated by a file of its own names it as `MODEL`; one that needs `n.optimize` keywords names them as `OPTIMIZE`; one that names `BRANCH_OUTAGES` is solved by -`n.optimize.optimize_security_constrained` over them. PyPSA is not a dependency +`n.optimize.optimize_security_constrained` over them. A rung that names `MGA` +is solved once more through `n.optimize.optimize_mga` with those keywords, and +that solve is the record. PyPSA is not a dependency of this project; this script pins the versions the recorded numbers are from, and the `PyPSA references` workflow runs `--check` on every change. """ @@ -46,9 +48,9 @@ def build(rung: str) -> pypsa.Network: return importlib.import_module(rung).build() -def keywords(rung: str) -> dict[str, object]: - """What the rung's `n.optimize` takes beyond the solver — the script's `OPTIMIZE`, if it names any.""" - return dict(getattr(importlib.import_module(rung), 'OPTIMIZE', {})) +def keywords(rung: str, call: str = 'OPTIMIZE') -> dict[str, object]: + """What the rung's `n.optimize` (or `optimize_mga`, for `MGA`) takes beyond the solver — the script's `call`, if it names one.""" + return dict(getattr(importlib.import_module(rung), call, {})) def record(n: pypsa.Network) -> dict[str, object]: @@ -76,6 +78,9 @@ def solved(rung: str) -> dict[str, object]: solver_name='highs', branch_outages=outages, **keywords(rung) ) assert status == 'ok', f'{rung}: HiGHS did not solve — {status} / {condition}' + if mga := keywords(rung, 'MGA'): + status, condition = n.optimize.optimize_mga(solver_name='highs', **mga) + assert status == 'ok', f'{rung}: HiGHS did not solve the MGA run — {status} / {condition}' return record(n) diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 62698829..398e7161 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1155,5 +1155,28 @@ "Transformer-fix-s-upper-security-for-Line-outage-in-sub-network-2": 8, "Transformer-fix-s-upper-security-for-Transformer-outage-in-sub-network-2": 4 } + }, + "rung_31_mga": { + "columns": { + "Generator-p": 20, + "Generator-p_nom": 2, + "Link-p": 4, + "objective_constant": 1 + }, + "global_constraints": {}, + "objective": -174.8541663000001, + "objective_constant": 200.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-ext-p-lower": 8, + "Generator-ext-p-upper": 8, + "Generator-ext-p_nom-lower": 2, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "budget": 1 + } } } diff --git a/examples/references/pypsa/rung_31_mga.py b/examples/references/pypsa/rung_31_mga.py new file mode 100644 index 00000000..55bd8522 --- /dev/null +++ b/examples/references/pypsa/rung_31_mga.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 31: MGA — the most wind a network builds within a tenth above its least cost.""" + +from __future__ import annotations + +import spine + +MGA = {'weights': {'Generator': {'p_nom': {'wind31': 1}}}, 'sense': 'max', 'slack': 0.1} + + +def build(): + """The spine plus a bus that extendable wind and gas and a fixed hydro unit with a capital cost serve; the wind carries a build already, so the objective has a constant.""" + n = spine.build() + n.add('Bus', 'mga31') + n.add( + 'Generator', + 'wind31', + bus='mga31', + p_nom=10, + p_nom_extendable=True, + capital_cost=20, + p_max_pu=[0.3, 0.9, 0.5, 0.6], + ) + n.add('Generator', 'gas31', bus='mga31', p_nom_extendable=True, capital_cost=8, marginal_cost=40) + n.add('Generator', 'hydro31', bus='mga31', p_nom=15, capital_cost=3, marginal_cost=5) + n.add('Load', 'mga31_load', bus='mga31', p_set=[50, 80, 60, 70]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 2f2eb235..790e06c9 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -295,6 +295,15 @@ names: Line_capital_weight: '\mathrm{W}^{s}' Process_capital_weight: '\mathrm{W}^{z}' Transformer_capital_weight: '\mathrm{W}^{\sigma}' + mga: '\mathrm{mga}' + mga_budget: '\overline{\mathrm{C}}' + Generator_mga_weight: '\beta' + Link_mga_weight: '\beta^{f}' + StorageUnit_mga_weight: '\beta^{h}' + Store_mga_weight: '\beta^{e}' + Line_mga_weight: '\beta^{s}' + Process_mga_weight: '\beta^{z}' + Transformer_mga_weight: '\beta^{\sigma}' Generator_first_active: '\mathrm{new}' Link_first_active: '\mathrm{new}^{f}' StorageUnit_first_active: '\mathrm{new}^{h}' diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 344a8561..2350e557 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -5,8 +5,9 @@ """`examples/pypsa.yaml` is the superset that collapses to the standard PyPSA model. The file always declares a `scenario` axis, a mask-only `period` axis, a `carrier` -axis and the CVaR rows. Fed one scenario, one period, all-active masks and unit -weights, every addition is a no-op and the standard model returns. The repository +axis, the CVaR rows and the MGA `budget` row. Fed one scenario, one period, +all-active masks, unit weights and a false `mga` flag, every addition is a no-op +and the standard model returns. The repository runs no solver, so the reduction is guarded structurally: `tests/fixtures/ pypsa_standard_shape.yaml` freezes the standard model's names and frames, and the only frame change a standard row may carry is a leading `scenario`. @@ -93,8 +94,16 @@ def test_the_extra_axes_and_rows_are_declared(): assert 'scenario_opex' in ALL.expressions, 'the per-scenario operating cost is a named expression' -def test_omega_blends_expectation_and_tail_in_the_objective(): - expr = ALL.objective.expression +def test_omega_blends_expectation_and_tail_in_the_system_cost(): + expr = ALL.expressions['system_cost'].expression assert all(term in expr for term in ('CVaR_omega', 'scenario_weight', 'scenario_opex', 'CVaR')), ( - 'the objective prices expected opex by scenario weight and blends the tail by omega' + 'the system cost prices expected opex by scenario weight and blends the tail by omega' ) + + +def test_a_run_without_the_mga_flag_minimises_the_system_cost_unbudgeted(): + goal = ALL.expressions['goal'] + assert ALL.objective.expression == 'goal', 'the objective is the goal' + assert goal.otherwise == 'system_cost', 'without the flag the goal is the system cost' + assert [case.when for case in goal.cases.values()] == ['mga'], 'only the mga flag switches the goal' + assert ALL.constraints['budget'].where == 'mga', 'without the flag there is no budget row' From 315151952e478121820108a1cb281791d10a02cd Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 14:15:53 +0200 Subject: [PATCH 17/75] docs(pypsa): mga leaves the standard spec until it can land as a patch file Reverts 51096860. The commit is kept on docs/pypsa-mga and waits for #571. --- docs/examples/pypsa.md | 184 +++------------------- examples/pypsa.yaml | 104 ++---------- examples/references/pypsa/reference.py | 13 +- examples/references/pypsa/references.json | 23 --- examples/references/pypsa/rung_31_mga.py | 30 ---- examples/symbols/pypsa.yaml | 9 -- tests/test_pypsa_collapses.py | 19 +-- 7 files changed, 46 insertions(+), 336 deletions(-) delete mode 100644 examples/references/pypsa/rung_31_mga.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 580bb1a5..5788a998 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -86,8 +86,8 @@ def build(): | [`Link-fix-p-upper`](#link-fix-p-upper) | done | | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | -| [`marginal_cost`](#system_cost) | done | | -| [`marginal_cost_quadratic`](#system_cost) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | +| [`marginal_cost`](#objective) | done | | +| [`marginal_cost_quadratic`](#objective) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | | `objective_constant` | split | an objective shift, compared net of `n._objective_constant` — rungs 11 and 13 carry a nonzero one, `21915277.52` and `160.0`, so the netting is under test | @@ -129,7 +129,7 @@ def build(): | [`StorageUnit-energy_balance`](#storageunit-energy_balance) | done | the charge carried into a snapshot is a cased quantity — cyclic, opening, carried; `(1-loss)**eh` is prep | | [`Store-energy_balance`](#store-energy_balance) | done | same | | [`StorageUnit-p_set`](#storageunit-p_set), [`{c}-{attr}_set`](#generator-p_set) | done | `Generator-p_set`, `Link-p_set`, `StorageUnit-state_of_charge_set`, `Store-e_set`, `Line-s_set` | -| [`marginal_cost_storage`, `spill_cost`](#system_cost) | done | | +| [`marginal_cost_storage`, `spill_cost`](#objective) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `4456.659315422356`, 103 rows. @@ -203,7 +203,7 @@ def build(): | [`{c}-ext-p_nom-lower/upper`](#generator-ext-p_nom-lower) | done | | | [`{c}-p_nom_set`](#generator-p_nom_set) | done | | | [`Generator-e_sum_min/max`](#generator-e_sum_min) | done | | -| [capital cost](#system_cost) | done | `periodized_cost` is an annuity, data prep | +| [capital cost](#objective) | done | `periodized_cost` is an annuity, data prep | > ✔ `pypsa 1.3.0` solves this rung's network at objective `7633.908502024292`, 184 rows. @@ -604,7 +604,7 @@ def build(): | [`{c}-com-up-time`, `-down-time`](#generator-com-up-time) | done | `sum_back(window=min_up_time)` | | [`{c}-com-status-min_up_time_must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | | [`{c}-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | the same prep mask over the down time brought in, status zero; PyPSA's name says `_must_stay_up`; rung 24 records it | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | | [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rung 12, a file of its own | @@ -823,7 +823,7 @@ feeds zero, so the term vanishes and the objective stays linear. | PyPSA | status | note | | --- | --- | --- | -| [`marginal_cost_quadratic`](#system_cost) | done | degree 2 in the objective; Generator and Link here | +| [`marginal_cost_quadratic`](#objective) | done | degree 2 in the objective; Generator and Link here | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12587.437500000098`, 60 rows. @@ -1304,7 +1304,7 @@ excess per scenario and the tail's average, blended into the objective at | [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | | [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | | [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | -| [objective](#system_cost) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | +| [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. @@ -1357,7 +1357,7 @@ own dimensions. | [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | -| [objective](#system_cost) | done | period weight on operation; capacity once per period it stands in | +| [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | @@ -1548,7 +1548,7 @@ generator's and the link's, read over a converter. | [`Process-p_set`](#process-p_set) | done | a fixed internal power schedule | | [`Process-p_nom_set`](#process-p_nom_set) | done | a fixed built capacity | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | each port enters at its `rate` | -| [objective](#system_cost) | done | marginal cost on internal power; capital on capacity | +| [objective](#objective) | done | marginal cost on internal power; capital on capacity | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9730.0`, 70 rows. @@ -1624,7 +1624,7 @@ transformer's built capacity. | [`Transformer-s_nom_set`](#transformer-s_nom_set) | done | a fixed built capacity | | [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) | done | rung 6, over `x_pu_eff` and a phase shift | | [`Transformer-phase_shift`](#variable-domains) | done | rung 20, an optimised phase shift | -| [objective](#system_cost) | done | capital on capacity | +| [objective](#objective) | done | capital on capacity | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12274.401472395122`, 106 rows. @@ -2047,7 +2047,7 @@ extendable, modular, or both. | [`Link-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#link-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | | [`Link-p-ramp_limit_*`, `-*-bigM`](#link-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over flow | | [`Link-p_nom_modularity`](#link-p_nom_modularity) | done | | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `14013.0`, 235 rows. @@ -2169,7 +2169,7 @@ time rules bind. | [`Process-com-status-min_up_time_must_stay_up`, `-min_down_time_must_stay_up`](#process-com-status-min_up_time_must_stay_up) | done | prep masks, as the generator's | | [`Process-p-ramp_limit_*`, `-*-bigM`](#process-p-ramp_limit_up) | done | the generator's cased allowance and big-M rows over internal power | | [`Process-p_nom_modularity`](#process-p_nom_modularity) | done | | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#system_cost) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | > ✔ `pypsa 1.3.0` solves this rung's network at objective `15956.125`, 235 rows. @@ -2552,77 +2552,6 @@ def build(): -### Rung 31 — MGA - -`optimize_mga` needs a solved network. It builds the model again, adds one -`budget` row, and replaces the objective (`mga.py:343`, `:350`, `:372`). The row -caps the model's objective plus `installed_capex` at `(1 + slack)` times -`capex + opex` of the optimum (`mga.py:256`, `:270`). Net of the objective -constant, that is `system_cost <= mga_budget`, and data prep computes the -budget from the optimum. The slack also scales the capital cost of fixed -assets, which the objective does not carry. In this rung, the 45 of fixed -hydro capex adds 4.5 to the cap. The new objective is the weights times the -variables they name, times `-1` to maximise (`mga.py:187`, `:372`). -`optimize_mga_in_direction` builds the same row and minimises the negated sum -of direction times dimension weights (`mga.py:508`, `:513`). Data prep folds -the sense and the direction into `{c}_mga_weight`, so one `goal` case covers -both. - -This rung maximises the wind build at a slack of 0.1. The optimum costs -`9884.17` and builds 116.67 of wind. The MGA run builds 174.85, and the -`budget` row holds with equality at dual `-0.05`. - -| PyPSA | status | note | -| --- | --- | --- | -| [`budget`](#budget) | done | the `mga` flag builds the row; `mga_budget` is `(1 + slack) * optimal_cost - fixed_cost` plus the objective constant, data prep. The multi-period and stochastic budgets are PyPSA's `statistics` sums, data prep, and no rung records them | -| [MGA objective](#goal) | done | `goal` is the weighted builds where `mga` holds and `system_cost` elsewhere; a plain run is the standard objective | -| `weights` on a variable other than a build | open | PyPSA takes any `{c}-{attr}`, for example `Generator-p` per snapshot; each is one more parameter and one more term of the same shape | -| `optimize_mga_in_multiple_directions`, `project_solved` | out | a loop of `optimize_mga_in_direction` runs, and a read-back of their solutions | - - -> ✔ `pypsa 1.3.0` solves this rung's network at objective `-174.8541663000001`, 63 rows. - -
-The network, as PyPSA code - -`rung_31_mga.py` - -```python -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -"""Rung 31: MGA — the most wind a network builds within a tenth above its least cost.""" - -from __future__ import annotations - -import spine - -MGA = {'weights': {'Generator': {'p_nom': {'wind31': 1}}}, 'sense': 'max', 'slack': 0.1} - - -def build(): - """The spine plus a bus that extendable wind and gas and a fixed hydro unit with a capital cost serve; the wind carries a build already, so the objective has a constant.""" - n = spine.build() - n.add('Bus', 'mga31') - n.add( - 'Generator', - 'wind31', - bus='mga31', - p_nom=10, - p_nom_extendable=True, - capital_cost=20, - p_max_pu=[0.3, 0.9, 0.5, 0.6], - ) - n.add('Generator', 'gas31', bus='mga31', p_nom_extendable=True, capital_cost=8, marginal_cost=40) - n.add('Generator', 'hydro31', bus='mga31', p_nom=15, capital_cost=3, marginal_cost=5) - n.add('Load', 'mga31_load', bus='mga31', p_set=[50, 80, 60, 70]) - return n -``` - -
- - ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2647,7 +2576,7 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. Under the `mga` flag the file is PyPSA's `optimize_mga`: the system cost is capped at a budget above its optimum, and weighted builds are the objective instead. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. #### Sets @@ -2761,15 +2690,6 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | | $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | -| $`\mathrm{mga}`$ | `mga` (scalar) — whether the run is PyPSA's `optimize_mga` or `optimize_mga_in_direction` — the system cost is capped by the `budget` row and the weighted builds are the objective; false is a plain `n.optimize()` | -| $`\overline{\mathrm{C}}`$ | `mga_budget` (scalar) — the most the system cost may reach under MGA — PyPSA's `(1 + slack) * optimal_cost - fixed_cost`, from the solved optimum's statistics, plus the objective constant this file leaves out of the cost; data prep | -| $`\beta`$ | `Generator_mga_weight` over $`\mathcal{G}`$ — what one unit of a generator's build weighs in the MGA objective — PyPSA's `weights['Generator']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{f}`$ | `Link_mga_weight` over $`\mathcal{L}`$ — what one unit of a link's build weighs in the MGA objective — PyPSA's `weights['Link']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{h}`$ | `StorageUnit_mga_weight` over $`\mathcal{S}`$ — what one unit of a storage unit's build weighs in the MGA objective — PyPSA's `weights['StorageUnit']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{e}`$ | `Store_mga_weight` over $`\mathcal{V}`$ — what one unit of a store's build weighs in the MGA objective — PyPSA's `weights['Store']['e_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{s}`$ | `Line_mga_weight` over $`\mathcal{K}`$ — what one unit of a line's build weighs in the MGA objective — PyPSA's `weights['Line']['s_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{z}`$ | `Process_mga_weight` over $`\mathcal{J}`$ — what one unit of a process's build weighs in the MGA objective — PyPSA's `weights['Process']['p_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | -| $`\beta^{\sigma}`$ | `Transformer_mga_weight` over $`\mathcal{M}`$ — what one unit of a transformer's build weighs in the MGA objective — PyPSA's `weights['Transformer']['s_nom']`, negated to maximise; a direction's weighted dimensions fold into it, data prep | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | | $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | @@ -2988,8 +2908,6 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | -| $`\mathit{system\_cost}`$ | `system_cost` (scalar) — what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail | -| $`\mathit{goal}`$ | `goal` (scalar) — what the run minimises — the system cost, or under MGA the weighted builds within the budget | Upright is what the model is given — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -3008,12 +2926,21 @@ $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation pu ```yaml objective: sense: minimize - description: the system cost; under MGA the weighted builds instead - expression: goal + description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR ``` ```math -\min \mathit{goal} +\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR ``` ### `Generator-fix-p-lower` @@ -6342,22 +6269,6 @@ CVaR_def: \theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR ``` -### `budget` - -`budget` - -```yaml -budget: - description: "`budget` — under MGA, the system cost stays within the slack above its optimum" - dims: [] - where: mga - expression: system_cost <= mga_budget -``` - -```math -\mathit{system\_cost} \le \overline{\mathrm{C}} \qquad \text{where } \mathrm{mga} -``` - ### `Generator_previous_status` ```yaml @@ -7200,51 +7111,6 @@ Outage_s: \hat{s}_{\xi,t,\kappa} = \begin{cases} \check{s}_{\xi,t,\mathrm{Outage\_line}(\kappa)} & \text{if } \mathrm{Outage\_line}(\kappa) \text{ is defined} \\ \check{\sigma}_{\xi,t,\mathrm{Outage\_transformer}(\kappa)} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ \kappa \in \mathcal{K}^{\mathrm{out}} ``` -### `system_cost` - -```yaml -system_cost: - description: what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR -``` - -```math -\mathit{system\_cost} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR -``` - -### `goal` - -```yaml -goal: - description: what the run minimises — the system cost, or under MGA the weighted builds within the budget - dims: [] - cases: - mga: - when: mga - expression: >- - sum(Generator_p_nom_ext * Generator_mga_weight) - + sum(Link_p_nom_ext * Link_mga_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_mga_weight) - + sum(Store_e_nom_ext * Store_mga_weight) - + sum(Line_s_nom_ext * Line_mga_weight) - + sum(Process_p_nom_ext * Process_mga_weight) - + sum(Transformer_s_nom_ext * Transformer_mga_weight) - otherwise: system_cost -``` - -```math -\mathit{goal} = \begin{cases} \sum_{g \in \mathcal{G}} P_{g} \cdot \beta_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \beta^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \beta^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \beta^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \beta^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \beta^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \beta^{\sigma}_{m} & \text{if } \mathrm{mga} \\ \mathit{system\_cost} & \text{otherwise} \end{cases} -``` - #### Variable domains **`Generator_p`** diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 3d853972..9b0d747b 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -13,9 +13,7 @@ description: >- model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the - outage factors are data prep. Under the `mga` flag the file is PyPSA's - `optimize_mga`: the system cost is capped at a budget above its optimum, and - weighted builds are the objective instead. + outage factors are data prep. dimensions: scenario: @@ -409,61 +407,6 @@ parameters: CVaR_inv_tail: description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor dims: [] - mga: - description: >- - whether the run is PyPSA's `optimize_mga` or `optimize_mga_in_direction` - — the system cost is capped by the `budget` row and the weighted builds - are the objective; false is a plain `n.optimize()` - dims: [] - dtype: bool - mga_budget: - description: >- - the most the system cost may reach under MGA — PyPSA's `(1 + slack) * - optimal_cost - fixed_cost`, from the solved optimum's statistics, plus - the objective constant this file leaves out of the cost; data prep - dims: [] - Generator_mga_weight: - description: >- - what one unit of a generator's build weighs in the MGA objective — PyPSA's - `weights['Generator']['p_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [generator] - Link_mga_weight: - description: >- - what one unit of a link's build weighs in the MGA objective — PyPSA's - `weights['Link']['p_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [link] - StorageUnit_mga_weight: - description: >- - what one unit of a storage unit's build weighs in the MGA objective — PyPSA's - `weights['StorageUnit']['p_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [storage_unit] - Store_mga_weight: - description: >- - what one unit of a store's build weighs in the MGA objective — PyPSA's - `weights['Store']['e_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [store] - Line_mga_weight: - description: >- - what one unit of a line's build weighs in the MGA objective — PyPSA's - `weights['Line']['s_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [line] - Process_mga_weight: - description: >- - what one unit of a process's build weighs in the MGA objective — PyPSA's - `weights['Process']['p_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [process] - Transformer_mga_weight: - description: >- - what one unit of a transformer's build weighs in the MGA objective — PyPSA's - `weights['Transformer']['s_nom']`, negated to maximise; a direction's weighted - dimensions fold into it, data prep - dims: [transformer] period_weight_objective: description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs dims: [period] @@ -1731,33 +1674,6 @@ expressions: cases: line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) - system_cost: - description: what `n.optimize()` minimises — capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR - goal: - description: what the run minimises — the system cost, or under MGA the weighted builds within the budget - dims: [] - cases: - mga: - when: mga - expression: >- - sum(Generator_p_nom_ext * Generator_mga_weight) - + sum(Link_p_nom_ext * Link_mga_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_mga_weight) - + sum(Store_e_nom_ext * Store_mga_weight) - + sum(Line_s_nom_ext * Line_mga_weight) - + sum(Process_p_nom_ext * Process_mga_weight) - + sum(Transformer_s_nom_ext * Transformer_mga_weight) - otherwise: system_cost constraints: Generator_fix_p_lower: @@ -3062,13 +2978,17 @@ constraints: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR - budget: - description: "`budget` — under MGA, the system cost stays within the slack above its optimum" - dims: [] - where: mga - expression: system_cost <= mga_budget objective: sense: minimize - description: the system cost; under MGA the weighted builds instead - expression: goal + description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR diff --git a/examples/references/pypsa/reference.py b/examples/references/pypsa/reference.py index b8df1f91..0c70a779 100644 --- a/examples/references/pypsa/reference.py +++ b/examples/references/pypsa/reference.py @@ -17,9 +17,7 @@ the PyPSA model under review is the script itself. A rung stated by a file of its own names it as `MODEL`; one that needs `n.optimize` keywords names them as `OPTIMIZE`; one that names `BRANCH_OUTAGES` is solved by -`n.optimize.optimize_security_constrained` over them. A rung that names `MGA` -is solved once more through `n.optimize.optimize_mga` with those keywords, and -that solve is the record. PyPSA is not a dependency +`n.optimize.optimize_security_constrained` over them. PyPSA is not a dependency of this project; this script pins the versions the recorded numbers are from, and the `PyPSA references` workflow runs `--check` on every change. """ @@ -48,9 +46,9 @@ def build(rung: str) -> pypsa.Network: return importlib.import_module(rung).build() -def keywords(rung: str, call: str = 'OPTIMIZE') -> dict[str, object]: - """What the rung's `n.optimize` (or `optimize_mga`, for `MGA`) takes beyond the solver — the script's `call`, if it names one.""" - return dict(getattr(importlib.import_module(rung), call, {})) +def keywords(rung: str) -> dict[str, object]: + """What the rung's `n.optimize` takes beyond the solver — the script's `OPTIMIZE`, if it names any.""" + return dict(getattr(importlib.import_module(rung), 'OPTIMIZE', {})) def record(n: pypsa.Network) -> dict[str, object]: @@ -78,9 +76,6 @@ def solved(rung: str) -> dict[str, object]: solver_name='highs', branch_outages=outages, **keywords(rung) ) assert status == 'ok', f'{rung}: HiGHS did not solve — {status} / {condition}' - if mga := keywords(rung, 'MGA'): - status, condition = n.optimize.optimize_mga(solver_name='highs', **mga) - assert status == 'ok', f'{rung}: HiGHS did not solve the MGA run — {status} / {condition}' return record(n) diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 398e7161..62698829 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1155,28 +1155,5 @@ "Transformer-fix-s-upper-security-for-Line-outage-in-sub-network-2": 8, "Transformer-fix-s-upper-security-for-Transformer-outage-in-sub-network-2": 4 } - }, - "rung_31_mga": { - "columns": { - "Generator-p": 20, - "Generator-p_nom": 2, - "Link-p": 4, - "objective_constant": 1 - }, - "global_constraints": {}, - "objective": -174.8541663000001, - "objective_constant": 200.0, - "pypsa": "1.3.0", - "rows": { - "Bus-nodal_balance": 12, - "Generator-ext-p-lower": 8, - "Generator-ext-p-upper": 8, - "Generator-ext-p_nom-lower": 2, - "Generator-fix-p-lower": 12, - "Generator-fix-p-upper": 12, - "Link-fix-p-lower": 4, - "Link-fix-p-upper": 4, - "budget": 1 - } } } diff --git a/examples/references/pypsa/rung_31_mga.py b/examples/references/pypsa/rung_31_mga.py deleted file mode 100644 index 55bd8522..00000000 --- a/examples/references/pypsa/rung_31_mga.py +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: math-spec Contributors -# -# SPDX-License-Identifier: MIT - -"""Rung 31: MGA — the most wind a network builds within a tenth above its least cost.""" - -from __future__ import annotations - -import spine - -MGA = {'weights': {'Generator': {'p_nom': {'wind31': 1}}}, 'sense': 'max', 'slack': 0.1} - - -def build(): - """The spine plus a bus that extendable wind and gas and a fixed hydro unit with a capital cost serve; the wind carries a build already, so the objective has a constant.""" - n = spine.build() - n.add('Bus', 'mga31') - n.add( - 'Generator', - 'wind31', - bus='mga31', - p_nom=10, - p_nom_extendable=True, - capital_cost=20, - p_max_pu=[0.3, 0.9, 0.5, 0.6], - ) - n.add('Generator', 'gas31', bus='mga31', p_nom_extendable=True, capital_cost=8, marginal_cost=40) - n.add('Generator', 'hydro31', bus='mga31', p_nom=15, capital_cost=3, marginal_cost=5) - n.add('Load', 'mga31_load', bus='mga31', p_set=[50, 80, 60, 70]) - return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 790e06c9..2f2eb235 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -295,15 +295,6 @@ names: Line_capital_weight: '\mathrm{W}^{s}' Process_capital_weight: '\mathrm{W}^{z}' Transformer_capital_weight: '\mathrm{W}^{\sigma}' - mga: '\mathrm{mga}' - mga_budget: '\overline{\mathrm{C}}' - Generator_mga_weight: '\beta' - Link_mga_weight: '\beta^{f}' - StorageUnit_mga_weight: '\beta^{h}' - Store_mga_weight: '\beta^{e}' - Line_mga_weight: '\beta^{s}' - Process_mga_weight: '\beta^{z}' - Transformer_mga_weight: '\beta^{\sigma}' Generator_first_active: '\mathrm{new}' Link_first_active: '\mathrm{new}^{f}' StorageUnit_first_active: '\mathrm{new}^{h}' diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 2350e557..344a8561 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -5,9 +5,8 @@ """`examples/pypsa.yaml` is the superset that collapses to the standard PyPSA model. The file always declares a `scenario` axis, a mask-only `period` axis, a `carrier` -axis, the CVaR rows and the MGA `budget` row. Fed one scenario, one period, -all-active masks, unit weights and a false `mga` flag, every addition is a no-op -and the standard model returns. The repository +axis and the CVaR rows. Fed one scenario, one period, all-active masks and unit +weights, every addition is a no-op and the standard model returns. The repository runs no solver, so the reduction is guarded structurally: `tests/fixtures/ pypsa_standard_shape.yaml` freezes the standard model's names and frames, and the only frame change a standard row may carry is a leading `scenario`. @@ -94,16 +93,8 @@ def test_the_extra_axes_and_rows_are_declared(): assert 'scenario_opex' in ALL.expressions, 'the per-scenario operating cost is a named expression' -def test_omega_blends_expectation_and_tail_in_the_system_cost(): - expr = ALL.expressions['system_cost'].expression +def test_omega_blends_expectation_and_tail_in_the_objective(): + expr = ALL.objective.expression assert all(term in expr for term in ('CVaR_omega', 'scenario_weight', 'scenario_opex', 'CVaR')), ( - 'the system cost prices expected opex by scenario weight and blends the tail by omega' + 'the objective prices expected opex by scenario weight and blends the tail by omega' ) - - -def test_a_run_without_the_mga_flag_minimises_the_system_cost_unbudgeted(): - goal = ALL.expressions['goal'] - assert ALL.objective.expression == 'goal', 'the objective is the goal' - assert goal.otherwise == 'system_cost', 'without the flag the goal is the system cost' - assert [case.when for case in goal.cases.values()] == ['mga'], 'only the mga flag switches the goal' - assert ALL.constraints['budget'].where == 'mga', 'without the flag there is no budget row' From 3f5192a32ebc92fc7599ae97b90f45fc7c47100e Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 23 Sep 2026 15:24:24 +0200 Subject: [PATCH 18/75] docs(pypsa): a storage built in a later period opens at its first active snapshot --- docs/examples/pypsa.md | 146 +++++++++++++++--- examples/pypsa.yaml | 76 +++++++-- examples/references/pypsa/references.json | 29 ++++ .../pypsa/rung_32_storage_later_period.py | 46 ++++++ examples/symbols/pypsa.yaml | 4 + 5 files changed, 265 insertions(+), 36 deletions(-) create mode 100644 examples/references/pypsa/rung_32_storage_later_period.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5788a998..b5b6a848 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1359,6 +1359,7 @@ own dimensions. | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) for storage built in a later period or retired early | done | rung 32 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. @@ -2552,6 +2553,91 @@ def build(): +### Rung 32 — storage that stands in one period only + +`n.optimize(multi_investment_periods=True)` with storage that is not per period +and does not stand in every period. PyPSA opens such a storage at the first +snapshot it stands in: on its initial level where it is not cyclic, and on the +level of the last snapshot it stands in where it is cyclic +(`constraints.py:2095-2097`, `2270-2273`). It reads the previous level through +a forward fill over the snapshots the storage does not stand in. Because a +build year and a lifetime make those snapshots one run at each end of the +horizon, the file states the same rows with two data-prep parameters. The +opening snapshot past the horizon's first is `{c}_opens_late`. The number of +snapshots the storage does not stand in is `{c}_inactive_snapshots`, and a +cyclic storage reaches back that many snapshots further. A plain run feeds +false and zero, so the rows collapse to the standard ones. + +The rung builds a cyclic storage unit and a store with an initial level of 5 in +2030, and a cyclic store that retires after 2020. The earlier file read the +level before 2030 as absent and dropped the opening row, so each storage +opened on any level it chose. With the same rows, the objective falls to +`5620.61` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance), at the first snapshot a storage stands in | done | the `cyclic` and `opening` cases hold at `position(snapshot) == 0` or at `{c}_opens_late`; the cyclic one shifts one snapshot and then `{c}_inactive_snapshots` more, `edge='wrap'` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7230.4866975671375`, 92 rows. + +
+The network, as PyPSA code + +`rung_32_storage_later_period.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 32: storage that stands in one period only — two built in the later period open at its first snapshot, and a cyclic one that retires closes on its own last snapshot.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, two storages built in 2030, one cyclic store that retires after 2020.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] + n.snapshot_weightings['stores'] = [0.5, 2.0, 1.5, 2.5, 0.5, 2.0, 1.5, 2.5] + n.add('Bus', 'hub') + n.add('Generator', 'base32', bus='hub', p_nom=100, marginal_cost=10) + n.add('Generator', 'peak32', bus='hub', p_nom=200, marginal_cost=[80, 20, 90, 30, 80, 20, 90, 30]) + n.add( + 'StorageUnit', + 'su_late', + bus='hub', + p_nom=15, + max_hours=4, + standing_loss=0.02, + cyclic_state_of_charge=True, + build_year=2030, + lifetime=30, + ) + n.add('Store', 'e_late', bus='hub', e_nom=30, e_initial=5, build_year=2030, lifetime=30) + n.add('Store', 'e_retire', bus='hub', e_nom=30, standing_loss=0.01, e_cyclic=True, build_year=2020, lifetime=10) + n.add('Load', 'hub_load', bus='hub', p_set=[40, 60, 70, 40, 90, 110, 120, 90]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2749,6 +2835,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | | $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every unit stands throughout | +| $`\mathrm{idle}`$ | `StorageUnit_inactive_snapshots` over $`\mathcal{S}`$ — how many snapshots a storage unit does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic unit reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | @@ -2763,6 +2851,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | | $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every store stands throughout | +| $`\mathrm{idle}^{e}`$ | `Store_inactive_snapshots` over $`\mathcal{V}`$ — how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic store reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | | $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | @@ -2894,8 +2984,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | -| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial charge at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise. Per period, the same holds with each investment period as the horizon | -| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — its last snapshot's less standing loss where it is cyclic, the given initial energy at the start of the horizon, which no standing loss has touched yet, and the previous snapshot's less standing loss otherwise. Per period, the same holds with each investment period as the horizon | +| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial charge, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A unit built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | +| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | @@ -6817,20 +6907,28 @@ Process_ramp_down_allowance: ```yaml StorageUnit_charge_carried_in: description: >- - the charge a unit opens a snapshot with — its last snapshot's less - standing loss where it is cyclic, the given initial charge at the start - of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise. Per period, the same holds with - each investment period as the horizon + the charge a unit opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial charge, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A unit built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon dims: [scenario, snapshot, storage_unit] cases: cyclic: - when: StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period AND NOT StorageUnit_state_of_charge_initial_per_period - expression: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap') + when: >- + StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: >- + StorageUnit_retention + * shift(shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=StorageUnit_inactive_snapshots, edge='wrap') opening: when: >- NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period - AND NOT StorageUnit_state_of_charge_initial_per_period AND position(snapshot) == 0 + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) expression: StorageUnit_state_of_charge_initial period_cyclic: when: StorageUnit_cyclic_state_of_charge_per_period @@ -6846,7 +6944,7 @@ StorageUnit_charge_carried_in: ``` ```math -\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \mathrm{pos}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \mathrm{reset}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} +\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,\left( t \ominus \mathrm{idle} \right) \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \mathrm{reset}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} ``` ### `Store_energy_carried_in` @@ -6854,18 +6952,26 @@ StorageUnit_charge_carried_in: ```yaml Store_energy_carried_in: description: >- - the energy a store opens a snapshot with — its last snapshot's less - standing loss where it is cyclic, the given initial energy at the start - of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise. Per period, the same holds with - each investment period as the horizon + the energy a store opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial energy, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A store built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon dims: [scenario, snapshot, store] cases: cyclic: - when: Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period - expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap') + when: >- + Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: >- + Store_retention + * shift(shift(Store_e, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=Store_inactive_snapshots, edge='wrap') opening: - when: NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period AND position(snapshot) == 0 + when: >- + NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) expression: Store_e_initial period_cyclic: when: Store_e_cyclic_per_period @@ -6877,7 +6983,7 @@ Store_energy_carried_in: ``` ```math -\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,t \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \mathrm{pos}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \mathrm{reset}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} +\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,\left( t \ominus \mathrm{idle}^{e} \right) \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \rho^{e}_{t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \mathrm{reset}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} ``` ### `Link_output_arrival` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 9b0d747b..ca421792 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -602,6 +602,20 @@ parameters: `multi_investment_periods`, so data prep feeds false otherwise dims: [storage_unit] dtype: bool + StorageUnit_opens_late: + description: >- + whether a snapshot is the first a storage unit stands in, where that is + not the first of the horizon — PyPSA's `active.cumsum() == 1` past the + first snapshot, data prep; false in a run where every unit stands throughout + dims: [snapshot, storage_unit] + dtype: bool + StorageUnit_inactive_snapshots: + description: >- + how many snapshots a storage unit does not stand in — PyPSA's + `(~active).sum()`, data prep. A cyclic unit reaches back this many + snapshots further, so it closes on the last snapshot it stands in + dims: [storage_unit] + dtype: int StorageUnit_marginal_cost: description: cost of one unit of dispatch dims: [snapshot, storage_unit] @@ -655,6 +669,20 @@ parameters: `multi_investment_periods`, so data prep feeds false otherwise dims: [store] dtype: bool + Store_opens_late: + description: >- + whether a snapshot is the first a store stands in, where that is not the + first of the horizon — PyPSA's `active.cumsum() == 1` past the first + snapshot, data prep; false in a run where every store stands throughout + dims: [snapshot, store] + dtype: bool + Store_inactive_snapshots: + description: >- + how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, + data prep. A cyclic store reaches back this many snapshots further, so it + closes on the last snapshot it stands in + dims: [store] + dtype: int Store_marginal_cost: description: cost of one unit of power delivered dims: [snapshot, store] @@ -1503,20 +1531,28 @@ expressions: otherwise: Process_ramp_down_rate * Process_p_nom_effective StorageUnit_charge_carried_in: description: >- - the charge a unit opens a snapshot with — its last snapshot's less - standing loss where it is cyclic, the given initial charge at the start - of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise. Per period, the same holds with - each investment period as the horizon + the charge a unit opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial charge, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A unit built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon dims: [scenario, snapshot, storage_unit] cases: cyclic: - when: StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period AND NOT StorageUnit_state_of_charge_initial_per_period - expression: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap') + when: >- + StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: >- + StorageUnit_retention + * shift(shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=StorageUnit_inactive_snapshots, edge='wrap') opening: when: >- NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period - AND NOT StorageUnit_state_of_charge_initial_per_period AND position(snapshot) == 0 + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) expression: StorageUnit_state_of_charge_initial period_cyclic: when: StorageUnit_cyclic_state_of_charge_per_period @@ -1531,18 +1567,26 @@ expressions: otherwise: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1) Store_energy_carried_in: description: >- - the energy a store opens a snapshot with — its last snapshot's less - standing loss where it is cyclic, the given initial energy at the start - of the horizon, which no standing loss has touched yet, and the previous - snapshot's less standing loss otherwise. Per period, the same holds with - each investment period as the horizon + the energy a store opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial energy, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A store built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon dims: [scenario, snapshot, store] cases: cyclic: - when: Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period - expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap') + when: >- + Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: >- + Store_retention + * shift(shift(Store_e, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=Store_inactive_snapshots, edge='wrap') opening: - when: NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period AND position(snapshot) == 0 + when: >- + NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) expression: Store_e_initial period_cyclic: when: Store_e_cyclic_per_period diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 62698829..e2db48d7 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1155,5 +1155,34 @@ "Transformer-fix-s-upper-security-for-Line-outage-in-sub-network-2": 8, "Transformer-fix-s-upper-security-for-Transformer-outage-in-sub-network-2": 4 } + }, + "rung_32_storage_later_period": { + "columns": { + "Generator-p": 16, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Store-e": 8, + "Store-p": 8 + }, + "global_constraints": {}, + "objective": 7230.4866975671375, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "StorageUnit-energy_balance": 4, + "StorageUnit-fix-p_dispatch-lower": 4, + "StorageUnit-fix-p_dispatch-upper": 4, + "StorageUnit-fix-p_store-lower": 4, + "StorageUnit-fix-p_store-upper": 4, + "StorageUnit-fix-state_of_charge-lower": 4, + "StorageUnit-fix-state_of_charge-upper": 4, + "Store-energy_balance": 8, + "Store-fix-e-lower": 8, + "Store-fix-e-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_32_storage_later_period.py b/examples/references/pypsa/rung_32_storage_later_period.py new file mode 100644 index 00000000..1d6ec685 --- /dev/null +++ b/examples/references/pypsa/rung_32_storage_later_period.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 32: storage that stands in one period only — two built in the later period open at its first snapshot, and a cyclic one that retires closes on its own last snapshot.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, two storages built in 2030, one cyclic store that retires after 2020.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 2.0, 1.5, 2.5, 2.0] + n.snapshot_weightings['stores'] = [0.5, 2.0, 1.5, 2.5, 0.5, 2.0, 1.5, 2.5] + n.add('Bus', 'hub') + n.add('Generator', 'base32', bus='hub', p_nom=100, marginal_cost=10) + n.add('Generator', 'peak32', bus='hub', p_nom=200, marginal_cost=[80, 20, 90, 30, 80, 20, 90, 30]) + n.add( + 'StorageUnit', + 'su_late', + bus='hub', + p_nom=15, + max_hours=4, + standing_loss=0.02, + cyclic_state_of_charge=True, + build_year=2030, + lifetime=30, + ) + n.add('Store', 'e_late', bus='hub', e_nom=30, e_initial=5, build_year=2030, lifetime=30) + n.add('Store', 'e_retire', bus='hub', e_nom=30, standing_loss=0.01, e_cyclic=True, build_year=2020, lifetime=10) + n.add('Load', 'hub_load', bus='hub', p_set=[40, 60, 70, 40, 90, 110, 120, 90]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 2f2eb235..d7ecaa05 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -254,6 +254,8 @@ names: StorageUnit_cyclic_state_of_charge: '\mathrm{cyc}' StorageUnit_cyclic_state_of_charge_per_period: '\mathrm{cyc}^{y}' StorageUnit_state_of_charge_initial_per_period: '\mathrm{reset}' + StorageUnit_opens_late: '\mathrm{open}' + StorageUnit_inactive_snapshots: '\mathrm{idle}' StorageUnit_marginal_cost: '\mathrm{c}^{h}' StorageUnit_marginal_cost_storage: '\mathrm{c}^{\mathrm{soc}}' StorageUnit_spill_cost: '\mathrm{c}^{\mathrm{spill}}' @@ -271,6 +273,8 @@ names: Store_e_cyclic: '\mathrm{cyc}^{e}' Store_e_cyclic_per_period: '\mathrm{cyc}^{e,y}' Store_e_initial_per_period: '\mathrm{reset}^{e}' + Store_opens_late: '\mathrm{open}^{e}' + Store_inactive_snapshots: '\mathrm{idle}^{e}' Store_marginal_cost: '\mathrm{c}^{q}' Store_marginal_cost_storage: '\mathrm{c}^{e}' Store_e_set: '\mathrm{e}^{\mathrm{set}}' From f0e167bcbd991a5bb569357b3154815032be351e Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 10:30:46 +0200 Subject: [PATCH 19/75] docs(pypsa): a unit may be scheduled off for maintenance --- docs/examples/pypsa.md | 1311 +++++++++++++++-- examples/pypsa.yaml | 471 +++++- examples/references/pypsa/references.json | 209 +++ .../references/pypsa/rung_33_maintenance.py | 82 ++ .../pypsa/rung_34_committable_maintenance.py | 99 ++ examples/symbols/pypsa.yaml | 24 + 6 files changed, 2015 insertions(+), 181 deletions(-) create mode 100644 examples/references/pypsa/rung_33_maintenance.py create mode 100644 examples/references/pypsa/rung_34_committable_maintenance.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index b5b6a848..9988d174 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -2638,6 +2638,272 @@ def build(): +### Rung 33 — maintenance + +A `Generator`, `Link` or `Process` with `maintainable=True` is taken off for +`maintenance_events` events (default 1) within the horizon. Each event covers +the snapshot it starts in and the snapshots after it, until their +`generators` weightings reach `maintenance_duration` hours +(`constraints.py:767-800`). While it is in maintenance, the component loses the +share `maintenance_pu` (default 1) of its build from both of its bounds +(`constraints.py:135-145`, `221-234`). The status `maintenance` is continuous in +[0, 1], and the binary starts make it whole (`variables.py:202-259`). An +extendable build multiplies a variable by a variable. PyPSA writes that product +as `maintenance_capacity` and holds it with four McCormick rows against +`p_nom_min` and `p_nom_max` (`constraints.py:810-845`), so PyPSA refuses an +extendable maintainable build with an infinite `p_nom_max`. + +The window of an event depends on the weightings, so its width varies along +`snapshot`. `sum_back` takes a width that does not vary along the dimension it +sums. So the file reads the windows as data: the relation +`*_maintenance_cover` pairs each start with the snapshots it covers. The mask +`*_maintenance_start_blocked` marks the starts whose window runs past the end +of the horizon or into a snapshot the component does not stand in. Both come +from `maintenance_duration`, the weightings and `active`. With them, each row +is the one PyPSA builds. Across investment periods an event may cross a period +boundary, and the count is over the whole horizon, as in PyPSA. Over scenarios +every maintenance variable is second stage, one schedule per scenario. + +Here every maintainable component is new, and a peaker at 60 covers the east +bus. With the same network and no `maintainable`, PyPSA solves to +`5388.833333333333`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-maintenance`, `-maintenance_start`, `-maintenance_capacity`](#variable-domains), and the `Link` and `Process` ones | done | zero where the component is not maintainable | +| [`Generator-maint-event-count`](#generator-maint-event-count), [`-maint-window`](#generator-maint-window), [`-maint-start-horizon`](#generator-maint-start-horizon), and the `Link` and `Process` ones | done | the window and the blocked starts are data prep | +| [`Generator-maintcap_*`](#generator-maintcap_upper), and the `Link` and `Process` ones | done | `-maintcap_lower_nommin` only where `p_nom_min > 0` | +| [`Generator-fix-p-*`](#generator-fix-p-lower), [`-ext-p-*`](#generator-ext-p-lower), and the `Link` and `Process` ones, in maintenance | done | the bound less `maintenance_pu` of the build | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7367.560185185185`, 179 rows. + +
+The network, as PyPSA code + +`rung_33_maintenance.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 33: maintenance — a fixed and an extendable generator, link and process, each taken off for events that the generator weightings time.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus and six maintainable components, fixed and extendable, with a dear peaker to cover them.""" + n = spine.build() + n.add('Bus', 'east') + n.add('Generator', 'hydro', bus='north', p_nom=50, marginal_cost=2, maintainable=True, maintenance_duration=3) + n.add( + 'Generator', + 'wind_ext', + bus='south', + p_nom_extendable=True, + p_nom_min=10, + p_nom_max=60, + capital_cost=20, + marginal_cost=1, + p_max_pu=[0.9, 0.6, 0.8, 0.7], + maintainable=True, + maintenance_duration=2, + maintenance_pu=0.5, + ) + n.add( + 'Link', + 'tie_fix', + bus0='north', + bus1='east', + p_nom=30, + efficiency=0.95, + maintainable=True, + maintenance_duration=1, + maintenance_events=2, + ) + n.add( + 'Link', + 'tie_ext', + bus0='south', + bus1='east', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=40, + capital_cost=4, + efficiency=0.9, + p_min_pu=-1, + maintainable=True, + maintenance_duration=3, + ) + n.add( + 'Process', + 'conv_fix', + bus0='north', + bus1='east', + rate0=-1.25, + p_nom=25, + maintainable=True, + maintenance_duration=3, + maintenance_pu=0.6, + ) + n.add( + 'Process', + 'conv_ext', + bus0='south', + bus1='east', + rate0=-1.25, + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=30, + capital_cost=3, + maintainable=True, + maintenance_duration=1, + ) + n.add('Generator', 'east_peak', bus='east', p_nom=100, marginal_cost=60) + n.add('Load', 'east_load', bus='east', p_set=[50, 60, 40, 55]) + return n +``` + +
+ + +### Rung 34 — maintenance of committable units + +A committable build scales its bounds by the status, so maintenance takes its +share off the status. PyPSA writes the product of the status and `maintenance` +as `maintenance_status`, with three McCormick rows, so a unit in maintenance +can also be off (`variables.py:301-338`, `constraints.py:425-458`). A modular +committable build uses the same product, bounded by the module count +`p_nom_max / p_nom_mod` (`constraints.py:498-533`). An extendable committable +build that is not modular uses `maintenance_capacity` in its big-M rows +instead (`constraints.py:363-373`). `-com-ext-p-upper-bigM` does not change. +Ramps, the up and down times and the storage rows do not read maintenance. + +With the same network and no `maintainable`, PyPSA solves to `5370.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-maintenance_status`](#variable-domains), and the `Link` and `Process` ones | done | | +| [`Generator-maint-status-*`](#generator-maint-status-le-status), [`-maint-modstatus-*`](#generator-maint-modstatus-le-status), and the `Link` and `Process` ones | done | a fixed build's status, and a modular build's module count | +| [`Generator-com-p-*`](#generator-com-p-lower), [`-com-mod-p-*`](#generator-com-mod-p-lower), [`-com-ext-p-lower`](#generator-com-ext-p-lower), [`-com-ext-p-upper-cap`](#generator-com-ext-p-upper-cap), and the `Link` and `Process` ones, in maintenance | done | | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `6420.048611111111`, 503 rows. + +
+The network, as PyPSA code + +`rung_34_committable_maintenance.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 34: maintenance of committable units — a fixed, an extendable and a modular committable generator, link and process, each taken off for one event.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus and nine maintainable committable components, with a dear peaker to cover them.""" + n = spine.build() + n.add('Bus', 'east') + committable = {'committable': True, 'maintainable': True} + n.add( + 'Generator', + 'unit', + bus='north', + p_nom=60, + p_min_pu=0.3, + marginal_cost=5, + start_up_cost=10, + maintenance_duration=2, + **committable, + ) + n.add( + 'Generator', + 'unit_ext', + bus='south', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=50, + capital_cost=10, + p_min_pu=0.2, + marginal_cost=4, + maintenance_duration=3, + maintenance_pu=0.5, + **committable, + ) + n.add( + 'Generator', + 'unit_mod', + bus='south', + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=30, + capital_cost=8, + p_min_pu=0.5, + marginal_cost=3, + maintenance_duration=1, + **committable, + ) + for component, ports in (('Link', {}), ('Process', {'rate0': -1.25})): + n.add( + component, + f'{component.lower()}_unit', + bus0='north', + bus1='east', + p_nom=40, + p_min_pu=0.2, + marginal_cost=1, + maintenance_duration=2, + **ports, + **committable, + ) + n.add( + component, + f'{component.lower()}_ext', + bus0='south', + bus1='east', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + maintenance_duration=3, + maintenance_pu=0.5, + **ports, + **committable, + ) + n.add( + component, + f'{component.lower()}_mod', + bus0='north', + bus1='east', + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=20, + capital_cost=3, + p_min_pu=0.5, + maintenance_duration=1, + **ports, + **committable, + ) + n.add('Generator', 'east_peak', bus='east', p_nom=100, marginal_cost=60) + n.add('Load', 'east_load', bus='east', p_set=[50, 60, 40, 55]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2654,6 +2920,8 @@ data prep, or harness — is one open question. Line numbers are pinned pypsa | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | | `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | +| `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | data prep, at `*_maintenance_cover` and `*_maintenance_start_blocked`; an infinite coefficient in the `maintcap` rows | | +| nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | an infinite coefficient in the `maint-modstatus` rows | | Duals and solutions are read back by the harness on the lpspec side: `marginal_price` is the balance dual over `w_objective`, `mu_upper` the @@ -2669,12 +2937,12 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | Symbol | Meaning | |---|---| | $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y},\ \mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Link\_maintenance\_cover} \subseteq \mathcal{L} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Process\_maintenance\_cover} \subseteq \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — generating units, each on one bus | +| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_maintenance\_cover} \subseteq \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_maintenance\_cover} \subseteq \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | | $`\mathcal{R}`$ | index $`r`$ — `process_output` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — a process's ports, one label per port a process declares — PyPSA's `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number of ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | @@ -2716,6 +2984,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{M}`$ | `Generator_big_m` over $`\mathcal{G}`$ — a bound safely above any feasible output — the build cap at full availability, data prep | | $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | +| $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\mathcal{G}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\mathcal{G}`$ — how many maintenance events the horizon holds | +| $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | @@ -2742,6 +3014,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\mathcal{L}`$ — a bound safely above any feasible flow — the build cap at full availability, data prep | | $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | +| $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon | +| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\mathcal{L}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\mathcal{L}`$ — how many maintenance events the horizon holds | +| $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | | $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | @@ -2772,6 +3048,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\mathcal{J}`$ — a bound safely above any feasible internal power — the build cap at full availability, data prep | | $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | +| $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon | +| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\mathcal{J}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\mathcal{J}`$ — how many maintenance events the horizon holds | +| $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | @@ -2926,14 +3206,26 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | +| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`N^{f}`$ | `Link_n_mod` over $`\mathcal{L}`$ — `Link-n_mod` — how many modules of an extendable modular build | | $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-status` — how much of a committable link is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is | | $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is | +| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`N^{z}`$ | `Process_n_mod` over $`\mathcal{J}`$ — `Process-n_mod` — how many modules of an extendable modular build | | $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-status` — how much of a committable process is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}^{z}`$ | `Process_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is | | $`\mathit{dn}^{z}`$ | `Process_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is | +| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | | $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | @@ -3042,11 +3334,11 @@ Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" dims: [scenario, snapshot, generator] where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-fix-p-upper` @@ -3058,11 +3350,11 @@ Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" dims: [scenario, snapshot, generator] where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Link-fix-p-lower` @@ -3074,11 +3366,11 @@ Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" dims: [scenario, snapshot, link] where: not Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom + expression: Link_p >= Link_p_min_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( 1 - \gamma^{f}_{l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-fix-p-upper` @@ -3090,11 +3382,11 @@ Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" dims: [scenario, snapshot, link] where: not Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom + expression: Link_p <= Link_p_max_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( 1 - \gamma^{f}_{l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Generator-ext-p-lower` @@ -3106,11 +3398,11 @@ Generator_ext_p_lower: description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" dims: [scenario, snapshot, generator] where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext + expression: Generator_p >= Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p-upper` @@ -3122,11 +3414,11 @@ Generator_ext_p_upper: description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" dims: [scenario, snapshot, generator] where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p_nom-lower` @@ -3218,11 +3510,11 @@ Link_ext_p_lower: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, link] where: Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom_ext + expression: Link_p >= Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p-upper` @@ -3234,11 +3526,11 @@ Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" dims: [scenario, snapshot, link] where: Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_ext + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p_nom-lower` @@ -3298,11 +3590,11 @@ Process_fix_p_lower: description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" dims: [scenario, snapshot, process] where: not Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom + expression: Process_p >= Process_p_min_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( 1 - \gamma^{z}_{j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-fix-p-upper` @@ -3314,11 +3606,11 @@ Process_fix_p_upper: description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" dims: [scenario, snapshot, process] where: not Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom + expression: Process_p <= Process_p_max_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( 1 - \gamma^{z}_{j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-lower` @@ -3330,11 +3622,11 @@ Process_ext_p_lower: description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, process] where: Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom_ext + expression: Process_p >= Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-upper` @@ -3346,11 +3638,11 @@ Process_ext_p_upper: description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" dims: [scenario, snapshot, process] where: Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p_nom-lower` @@ -3508,11 +3800,11 @@ Generator_com_p_lower: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" dims: [scenario, snapshot, generator] where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-p-upper` @@ -3524,11 +3816,11 @@ Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" dims: [scenario, snapshot, generator] where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-transition-start-up` @@ -3766,11 +4058,11 @@ Generator_com_ext_p_upper_cap: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot P_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-upper-bigM` @@ -3802,12 +4094,12 @@ Generator_com_ext_p_lower: where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: >- Generator_p >= - Generator_p_min_pu * Generator_p_nom_ext + Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_big_m * Generator_status - Generator_big_m ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot P_{g} + \mathrm{M}_{g} \cdot u_{\xi,t,g} - \mathrm{M}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) + \mathrm{M}_{g} \cdot u_{\xi,t,g} - \mathrm{M}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-lower-nonneg` @@ -3842,11 +4134,11 @@ Generator_com_mod_p_lower: its minimum of one module, whether the build is fixed or a decision dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-mod-p-upper` @@ -3860,11 +4152,11 @@ Generator_com_mod_p_upper: one module's share, whether the build is fixed or a decision dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-status-p-fixed-upper` @@ -3972,6 +4264,221 @@ Generator_shut_down_p_nom_variable_upper: \mathit{dn}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` +### `Generator-maint-event-count` + +`Generator_maint_event_count` + +```yaml +Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [scenario, generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events +``` + +```math +\sum_{t \in \mathcal{T}} \mu^{\mathrm{up}}_{\xi,t,g} = \mathrm{n}^{\mathrm{mnt}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator-maint-window` + +`Generator_maint_window` + +```yaml +Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) +``` + +```math +\mu_{\xi,t,g} = \sum_{t' \in \mathcal{T} \,:\, \left( g,\ t',\ t \right) \in \mathrm{Generator\_maintenance\_cover}} \mu^{\mathrm{up}}_{\xi,t',g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-start-horizon` + +`Generator_maint_start_horizon` + +```yaml +Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 +``` + +```math +\mu^{\mathrm{up}}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} \wedge \mathrm{blk}_{t,g} +``` + +### `Generator-maintcap_upper` + +`Generator_maintcap_upper` + +```yaml +Generator_maintcap_upper: + description: >- + `Generator-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_ext - Generator_p_nom_min * (1 - Generator_maintenance) +``` + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \le P_{g} - \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maintcap_upper_nommax` + +`Generator_maintcap_upper_nommax` + +```yaml +Generator_maintcap_upper_nommax: + description: "`Generator-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_max * Generator_maintenance +``` + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maintcap_lower_nommax` + +`Generator_maintcap_lower_nommax` + +```yaml +Generator_maintcap_lower_nommax: + description: "`Generator-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity >= Generator_p_nom_ext - Generator_p_nom_max * (1 - Generator_maintenance) +``` + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge P_{g} - \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maintcap_lower_nommin` + +`Generator_maintcap_lower_nommin` + +```yaml +Generator_maintcap_lower_nommin: + description: "`Generator-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active AND Generator_p_nom_min > 0 + expression: Generator_maintenance_capacity >= Generator_p_nom_min * Generator_maintenance +``` + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} \wedge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} > 0 +``` + +### `Generator-maint-status-le-status` + +`Generator_maint_status_le_status` + +```yaml +Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_status +``` + +```math +\mu^{u}_{\xi,t,g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-status-le-maint` + +`Generator_maint_status_le_maint` + +```yaml +Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_maintenance +``` + +```math +\mu^{u}_{\xi,t,g} \le \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-status-lb` + +`Generator_maint_status_lb` + +```yaml +Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 +``` + +```math +\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} + \mu_{\xi,t,g} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-modstatus-le-status` + +`Generator_maint_modstatus_le_status` + +```yaml +Generator_maint_modstatus_le_status: + description: "`Generator-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_status +``` + +```math +\mu^{u}_{\xi,t,g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-modstatus-le-maint` + +`Generator_maint_modstatus_le_maint` + +```yaml +Generator_maint_modstatus_le_maint: + description: >- + `Generator-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_p_nom_max / Generator_p_nom_mod * Generator_maintenance +``` + +```math +\mu^{u}_{\xi,t,g} \le \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +### `Generator-maint-modstatus-lb` + +`Generator_maint_modstatus_lb` + +```yaml +Generator_maint_modstatus_lb: + description: "`Generator-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status >= Generator_status - Generator_p_nom_max / Generator_p_nom_mod * (1 - Generator_maintenance) +``` + +```math +\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} - \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + ### `Link-com-p-lower` `Link_com_p_lower` @@ -3981,11 +4488,11 @@ Link_com_p_lower: description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" dims: [scenario, snapshot, link] where: Link_committable AND not Link_p_nom_extendable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom * Link_status + expression: Link_p >= Link_p_min_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-p-upper` @@ -3997,11 +4504,11 @@ Link_com_p_upper: description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" dims: [scenario, snapshot, link] where: Link_committable AND not Link_p_nom_extendable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom * Link_status + expression: Link_p <= Link_p_max_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-transition-start-up` @@ -4239,11 +4746,11 @@ Link_com_ext_p_upper_cap: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_ext + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot F_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-ext-p-upper-bigM` @@ -4259,190 +4766,405 @@ Link_com_ext_p_upper_big_m: ``` ```math -f_{\xi,t,l} \le \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-ext-p-lower` + +`Link_com_ext_p_lower` + +```yaml +Link_com_ext_p_lower: + description: >- + `Link-com-ext-p-lower` — a committed extendable link flows at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: >- + Link_p >= + Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + + Link_big_m * Link_status - Link_big_m +``` + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) + \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-ext-p-lower-nonneg` + +`Link_com_ext_p_lower_nonneg` + +```yaml +Link_com_ext_p_lower_nonneg: + description: >- + `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, flow is also plainly non-negative, a row the big-M lower + cannot assert while the link is off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable + AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p >= 0 +``` + +```math +f_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{nonneg}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-mod-p-lower` + +`Link_com_mod_p_lower` + +```yaml +Link_com_mod_p_lower: + description: >- + `Link-com-mod-p-lower` — a committed modular link flows at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) +``` + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-com-mod-p-upper` + +`Link_com_mod_p_upper` + +```yaml +Link_com_mod_p_upper: + description: >- + `Link-com-mod-p-upper` — a committed modular link flows at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) +``` + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-status-p-fixed-upper` + +`Link_status_p_fixed_upper` + +```yaml +Link_status_p_fixed_upper: + description: >- + `Link-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_status <= Link_modules_installed +``` + +```math +u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-start_up-p-fixed-upper` + +`Link_start_up_p_fixed_upper` + +```yaml +Link_start_up_p_fixed_upper: + description: >- + `Link-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_start_up <= Link_modules_installed +``` + +```math +\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-shut_down-p-fixed-upper` + +`Link_shut_down_p_fixed_upper` + +```yaml +Link_shut_down_p_fixed_upper: + description: >- + `Link-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_shut_down <= Link_modules_installed +``` + +```math +\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-status-p_nom-variable-upper` + +`Link_status_p_nom_variable_upper` + +```yaml +Link_status_p_nom_variable_upper: + description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_status <= Link_n_mod +``` + +```math +u^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-start_up-p_nom-variable-upper` + +`Link_start_up_p_nom_variable_upper` + +```yaml +Link_start_up_p_nom_variable_upper: + description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_start_up <= Link_n_mod +``` + +```math +\mathit{up}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-shut_down-p_nom-variable-upper` + +`Link_shut_down_p_nom_variable_upper` + +```yaml +Link_shut_down_p_nom_variable_upper: + description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_shut_down <= Link_n_mod +``` + +```math +\mathit{dn}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-maint-event-count` + +`Link_maint_event_count` + +```yaml +Link_maint_event_count: + description: "`Link-maint-event-count` — a maintainable link holds its number of maintenance events over the horizon" + dims: [scenario, link] + where: Link_maintainable + expression: sum(Link_maintenance_start, over=snapshot) == Link_maintenance_events +``` + +```math +\sum_{t \in \mathcal{T}} \mu^{f,\mathrm{up}}_{\xi,t,l} = \mathrm{n}^{f,\mathrm{mnt}}_{l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +### `Link-maint-window` + +`Link_maint_window` + +```yaml +Link_maint_window: + description: >- + `Link-maint-window` — a link is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + expression: Link_maintenance == sum(Link_maintenance_start, by=Link_maintenance_cover, over=start, into=covered) +``` + +```math +\mu^{f}_{\xi,t,l} = \sum_{t' \in \mathcal{T} \,:\, \left( l,\ t',\ t \right) \in \mathrm{Link\_maintenance\_cover}} \mu^{f,\mathrm{up}}_{\xi,t',l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +### `Link-maint-start-horizon` + +`Link_maint_start_horizon` + +```yaml +Link_maint_start_horizon: + description: "`Link-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active AND Link_maintenance_start_blocked + expression: Link_maintenance_start == 0 +``` + +```math +\mu^{f,\mathrm{up}}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} \wedge \mathrm{blk}^{f}_{t,l} ``` -### `Link-com-ext-p-lower` +### `Link-maintcap_upper` -`Link_com_ext_p_lower` +`Link_maintcap_upper` ```yaml -Link_com_ext_p_lower: +Link_maintcap_upper: description: >- - `Link-com-ext-p-lower` — a committed extendable link flows at - least its minimum of the chosen build; off, the big M releases the row + `Link-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active - expression: >- - Link_p >= - Link_p_min_pu * Link_p_nom_ext - + Link_big_m * Link_status - Link_big_m + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_ext - Link_p_nom_min * (1 - Link_maintenance) ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot F_{l} + \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le F_{l} - \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-com-ext-p-lower-nonneg` +### `Link-maintcap_upper_nommax` -`Link_com_ext_p_lower_nonneg` +`Link_maintcap_upper_nommax` ```yaml -Link_com_ext_p_lower_nonneg: - description: >- - `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is - negative, flow is also plainly non-negative, a row the big-M lower - cannot assert while the link is off +Link_maintcap_upper_nommax: + description: "`Link-maintcap_upper_nommax` — out of maintenance, no build is taken off" dims: [scenario, snapshot, link] - where: >- - Link_committable AND Link_p_nom_extendable - AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active - expression: Link_p >= 0 + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_max * Link_maintenance ``` ```math -f_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{nonneg}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-com-mod-p-lower` +### `Link-maintcap_lower_nommax` -`Link_com_mod_p_lower` +`Link_maintcap_lower_nommax` ```yaml -Link_com_mod_p_lower: - description: >- - `Link-com-mod-p-lower` — a committed modular link flows at least - its minimum of one module, whether the build is fixed or a decision +Link_maintcap_lower_nommax: + description: "`Link-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * Link_status + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity >= Link_p_nom_ext - Link_p_nom_max * (1 - Link_maintenance) ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge F_{l} - \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-com-mod-p-upper` +### `Link-maintcap_lower_nommin` -`Link_com_mod_p_upper` +`Link_maintcap_lower_nommin` ```yaml -Link_com_mod_p_upper: - description: >- - `Link-com-mod-p-upper` — a committed modular link flows at most - one module's share, whether the build is fixed or a decision +Link_maintcap_lower_nommin: + description: "`Link-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * Link_status + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active AND Link_p_nom_min > 0 + expression: Link_maintenance_capacity >= Link_p_nom_min * Link_maintenance ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} \wedge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} > 0 ``` -### `Link-status-p-fixed-upper` +### `Link-maint-status-le-status` -`Link_status_p_fixed_upper` +`Link_maint_status_le_status` ```yaml -Link_status_p_fixed_upper: - description: >- - `Link-status-p-fixed-upper` — a status is at most the modules in - place, an explicit row as PyPSA writes it: one where the build is not - modular, and the fixed build's whole count of modules where it is +Link_maint_status_le_status: + description: "`Link-maint-status-le-status` — the status in maintenance is at most the status" dims: [scenario, snapshot, link] - where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active - expression: Link_status <= Link_modules_installed + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_status ``` ```math -u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-start_up-p-fixed-upper` +### `Link-maint-status-le-maint` -`Link_start_up_p_fixed_upper` +`Link_maint_status_le_maint` ```yaml -Link_start_up_p_fixed_upper: - description: >- - `Link-start_up-p-fixed-upper` — a start is at most the modules in - place, an explicit row as PyPSA writes it: one where the build is not - modular, and the fixed build's whole count of modules where it is +Link_maint_status_le_maint: + description: "`Link-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" dims: [scenario, snapshot, link] - where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active - expression: Link_start_up <= Link_modules_installed + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_maintenance ``` ```math -\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \le \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-shut_down-p-fixed-upper` +### `Link-maint-status-lb` -`Link_shut_down_p_fixed_upper` +`Link_maint_status_lb` ```yaml -Link_shut_down_p_fixed_upper: - description: >- - `Link-shut_down-p-fixed-upper` — a stop is at most the modules in - place, an explicit row as PyPSA writes it: one where the build is not - modular, and the fixed build's whole count of modules where it is +Link_maint_status_lb: + description: "`Link-maint-status-lb` — on and in maintenance, the status in maintenance is one" dims: [scenario, snapshot, link] - where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active - expression: Link_shut_down <= Link_modules_installed + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status >= Link_status + Link_maintenance - 1 ``` ```math -\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} + \mu^{f}_{\xi,t,l} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-status-p_nom-variable-upper` +### `Link-maint-modstatus-le-status` -`Link_status_p_nom_variable_upper` +`Link_maint_modstatus_le_status` ```yaml -Link_status_p_nom_variable_upper: - description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" +Link_maint_modstatus_le_status: + description: "`Link-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_status <= Link_n_mod + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_status ``` ```math -u^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-start_up-p_nom-variable-upper` +### `Link-maint-modstatus-le-maint` -`Link_start_up_p_nom_variable_upper` +`Link_maint_modstatus_le_maint` ```yaml -Link_start_up_p_nom_variable_upper: - description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" +Link_maint_modstatus_le_maint: + description: >- + `Link-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_start_up <= Link_n_mod + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_p_nom_max / Link_p_nom_mod * Link_maintenance ``` ```math -\mathit{up}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \le \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` -### `Link-shut_down-p_nom-variable-upper` +### `Link-maint-modstatus-lb` -`Link_shut_down_p_nom_variable_upper` +`Link_maint_modstatus_lb` ```yaml -Link_shut_down_p_nom_variable_upper: - description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" +Link_maint_modstatus_lb: + description: "`Link-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" dims: [scenario, snapshot, link] - where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_shut_down <= Link_n_mod + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status >= Link_status - Link_p_nom_max / Link_p_nom_mod * (1 - Link_maintenance) ``` ```math -\mathit{dn}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-com-p-lower` @@ -4454,11 +5176,11 @@ Process_com_p_lower: description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" dims: [scenario, snapshot, process] where: Process_committable AND not Process_p_nom_extendable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom * Process_status + expression: Process_p >= Process_p_min_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-p-upper` @@ -4470,11 +5192,11 @@ Process_com_p_upper: description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" dims: [scenario, snapshot, process] where: Process_committable AND not Process_p_nom_extendable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom * Process_status + expression: Process_p <= Process_p_max_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-transition-start-up` @@ -4712,11 +5434,11 @@ Process_com_ext_p_upper_cap: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot Z_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-ext-p-upper-bigM` @@ -4748,12 +5470,12 @@ Process_com_ext_p_lower: where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active expression: >- Process_p >= - Process_p_min_pu * Process_p_nom_ext + Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_big_m * Process_status - Process_big_m ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot Z_{j} + \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) + \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-ext-p-lower-nonneg` @@ -4788,11 +5510,11 @@ Process_com_mod_p_lower: its minimum of one module, whether the build is fixed or a decision dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_mod > 0 AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * Process_status + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-mod-p-upper` @@ -4806,11 +5528,11 @@ Process_com_mod_p_upper: one module's share, whether the build is fixed or a decision dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_mod > 0 AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * Process_status + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-status-p-fixed-upper` @@ -4918,6 +5640,221 @@ Process_shut_down_p_nom_variable_upper: \mathit{dn}^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` +### `Process-maint-event-count` + +`Process_maint_event_count` + +```yaml +Process_maint_event_count: + description: "`Process-maint-event-count` — a maintainable process holds its number of maintenance events over the horizon" + dims: [scenario, process] + where: Process_maintainable + expression: sum(Process_maintenance_start, over=snapshot) == Process_maintenance_events +``` + +```math +\sum_{t \in \mathcal{T}} \mu^{z,\mathrm{up}}_{\xi,t,j} = \mathrm{n}^{z,\mathrm{mnt}}_{j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +### `Process-maint-window` + +`Process_maint_window` + +```yaml +Process_maint_window: + description: >- + `Process-maint-window` — a process is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + expression: Process_maintenance == sum(Process_maintenance_start, by=Process_maintenance_cover, over=start, into=covered) +``` + +```math +\mu^{z}_{\xi,t,j} = \sum_{t' \in \mathcal{T} \,:\, \left( j,\ t',\ t \right) \in \mathrm{Process\_maintenance\_cover}} \mu^{z,\mathrm{up}}_{\xi,t',j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-start-horizon` + +`Process_maint_start_horizon` + +```yaml +Process_maint_start_horizon: + description: "`Process-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active AND Process_maintenance_start_blocked + expression: Process_maintenance_start == 0 +``` + +```math +\mu^{z,\mathrm{up}}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} \wedge \mathrm{blk}^{z}_{t,j} +``` + +### `Process-maintcap_upper` + +`Process_maintcap_upper` + +```yaml +Process_maintcap_upper: + description: >- + `Process-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_ext - Process_p_nom_min * (1 - Process_maintenance) +``` + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le Z_{j} - \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maintcap_upper_nommax` + +`Process_maintcap_upper_nommax` + +```yaml +Process_maintcap_upper_nommax: + description: "`Process-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_max * Process_maintenance +``` + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maintcap_lower_nommax` + +`Process_maintcap_lower_nommax` + +```yaml +Process_maintcap_lower_nommax: + description: "`Process-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity >= Process_p_nom_ext - Process_p_nom_max * (1 - Process_maintenance) +``` + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge Z_{j} - \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maintcap_lower_nommin` + +`Process_maintcap_lower_nommin` + +```yaml +Process_maintcap_lower_nommin: + description: "`Process-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active AND Process_p_nom_min > 0 + expression: Process_maintenance_capacity >= Process_p_nom_min * Process_maintenance +``` + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} \wedge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} > 0 +``` + +### `Process-maint-status-le-status` + +`Process_maint_status_le_status` + +```yaml +Process_maint_status_le_status: + description: "`Process-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_status +``` + +```math +\mu^{z,u}_{\xi,t,j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-status-le-maint` + +`Process_maint_status_le_maint` + +```yaml +Process_maint_status_le_maint: + description: "`Process-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_maintenance +``` + +```math +\mu^{z,u}_{\xi,t,j} \le \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-status-lb` + +`Process_maint_status_lb` + +```yaml +Process_maint_status_lb: + description: "`Process-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status >= Process_status + Process_maintenance - 1 +``` + +```math +\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} + \mu^{z}_{\xi,t,j} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-modstatus-le-status` + +`Process_maint_modstatus_le_status` + +```yaml +Process_maint_modstatus_le_status: + description: "`Process-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_status +``` + +```math +\mu^{z,u}_{\xi,t,j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-modstatus-le-maint` + +`Process_maint_modstatus_le_maint` + +```yaml +Process_maint_modstatus_le_maint: + description: >- + `Process-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_p_nom_max / Process_p_nom_mod * Process_maintenance +``` + +```math +\mu^{z,u}_{\xi,t,j} \le \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +### `Process-maint-modstatus-lb` + +`Process_maint_modstatus_lb` + +```yaml +Process_maint_modstatus_lb: + description: "`Process-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status >= Process_status - Process_p_nom_max / Process_p_nom_mod * (1 - Process_maintenance) +``` + +```math +\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + ### `Line-fix-s-lower` `Line_fix_s_lower` @@ -7297,6 +8234,30 @@ u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \ \mathit{dn}_{\xi,t,g} \ge 0, \mathit{dn}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` +**`Generator_maintenance`** + +```math +0 \le \mu_{\xi,t,g} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_start`** + +```math +\mu^{\mathrm{up}}_{\xi,t,g} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_capacity`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_status`** + +```math +\mu^{u}_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \mathrm{on}_{t,g} +``` + **`Link_n_mod`** ```math @@ -7321,6 +8282,30 @@ u^{f}_{\xi,t,l} \ge 0, u^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \ \mathit{dn}^{f}_{\xi,t,l} \ge 0, \mathit{dn}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` +**`Link_maintenance`** + +```math +0 \le \mu^{f}_{\xi,t,l} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_start`** + +```math +\mu^{f,\mathrm{up}}_{\xi,t,l} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_capacity`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_status`** + +```math +\mu^{f,u}_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + **`Process_n_mod`** ```math @@ -7345,6 +8330,30 @@ u^{z}_{\xi,t,j} \ge 0, u^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \ \mathit{dn}^{z}_{\xi,t,j} \ge 0, \mathit{dn}^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` +**`Process_maintenance`** + +```math +0 \le \mu^{z}_{\xi,t,j} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_start`** + +```math +\mu^{z,\mathrm{up}}_{\xi,t,j} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_capacity`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_status`** + +```math +\mu^{z,u}_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + **`Line_s`** ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index ca421792..a400d145 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -163,6 +163,25 @@ parameters: PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep dims: [generator] dtype: bool + Generator_maintainable: + description: whether a generator must be taken off for maintenance within the horizon + dims: [generator] + dtype: bool + Generator_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [generator] + Generator_maintenance_events: + description: how many maintenance events the horizon holds + dims: [generator] + dtype: int + Generator_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the generator does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [snapshot, generator] + dtype: bool Link_ramp_limit_up: description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit dims: [link] @@ -272,6 +291,25 @@ parameters: PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep dims: [link] dtype: bool + Link_maintainable: + description: whether a link must be taken off for maintenance within the horizon + dims: [link] + dtype: bool + Link_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [link] + Link_maintenance_events: + description: how many maintenance events the horizon holds + dims: [link] + dtype: int + Link_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the link does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [snapshot, link] + dtype: bool Process_p_nom: description: nominal internal power dims: [process] @@ -395,6 +433,25 @@ parameters: PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep dims: [process] dtype: bool + Process_maintainable: + description: whether a process must be taken off for maintenance within the horizon + dims: [process] + dtype: bool + Process_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [process] + Process_maintenance_events: + description: how many maintenance events the horizon holds + dims: [process] + dtype: int + Process_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the process does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [snapshot, process] + dtype: bool Load_p_set: description: demand dims: [scenario, snapshot, load] @@ -1012,6 +1069,27 @@ relations: description: the transformer an outage takes out; an outage of a line has no row key: outage values: transformer + Generator_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + the start and the snapshots after it, until their generator weightings + reach `maintenance_duration`, data prep; no rows for a generator that is + not maintainable + key: { generator: generator, start: snapshot, covered: snapshot } + Link_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + the start and the snapshots after it, until their generator weightings + reach `maintenance_duration`, data prep; no rows for a link that is + not maintainable + key: { link: link, start: snapshot, covered: snapshot } + Process_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + the start and the snapshots after it, until their generator weightings + reach `maintenance_duration`, data prep; no rows for a process that is + not maintainable + key: { process: process, start: snapshot, covered: snapshot } variables: Generator_p: @@ -1094,6 +1172,45 @@ variables: domain: integer bounds: lower: 0 + Generator_maintenance: + description: >- + `Generator-maintenance` — whether a maintainable generator is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + bounds: + lower: 0 + upper: 1 + Generator_maintenance_start: + description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + domain: binary + Generator_maintenance_capacity: + description: >- + `Generator-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_p_nom_extendable + AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + absence: zero + bounds: + lower: 0 + Generator_maintenance_status: + description: >- + `Generator-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_committable + AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active + absence: zero + bounds: + lower: 0 Link_n_mod: description: "`Link-n_mod` — how many modules of an extendable modular build" dims: [link] @@ -1125,6 +1242,45 @@ variables: domain: integer bounds: lower: 0 + Link_maintenance: + description: >- + `Link-maintenance` — whether a maintainable link is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + bounds: + lower: 0 + upper: 1 + Link_maintenance_start: + description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + domain: binary + Link_maintenance_capacity: + description: >- + `Link-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_p_nom_extendable + AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + absence: zero + bounds: + lower: 0 + Link_maintenance_status: + description: >- + `Link-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_committable + AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active + absence: zero + bounds: + lower: 0 Process_n_mod: description: "`Process-n_mod` — how many modules of an extendable modular build" dims: [process] @@ -1156,6 +1312,45 @@ variables: domain: integer bounds: lower: 0 + Process_maintenance: + description: >- + `Process-maintenance` — whether a maintainable process is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + bounds: + lower: 0 + upper: 1 + Process_maintenance_start: + description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + domain: binary + Process_maintenance_capacity: + description: >- + `Process-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_p_nom_extendable + AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + absence: zero + bounds: + lower: 0 + Process_maintenance_status: + description: >- + `Process-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_committable + AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active + absence: zero + bounds: + lower: 0 Line_s: description: >- `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a @@ -1724,32 +1919,32 @@ constraints: description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" dims: [scenario, snapshot, generator] where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" dims: [scenario, snapshot, generator] where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) Link_fix_p_lower: description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" dims: [scenario, snapshot, link] where: not Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom + expression: Link_p >= Link_p_min_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) Link_fix_p_upper: description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" dims: [scenario, snapshot, link] where: not Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom + expression: Link_p <= Link_p_max_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) Generator_ext_p_lower: description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" dims: [scenario, snapshot, generator] where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_ext + expression: Generator_p >= Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) Generator_ext_p_upper: description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" dims: [scenario, snapshot, generator] where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) Generator_ext_p_nom_lower: description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" dims: [generator] @@ -1779,12 +1974,12 @@ constraints: description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, link] where: Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom_ext + expression: Link_p >= Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) Link_ext_p_upper: description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" dims: [scenario, snapshot, link] where: Link_p_nom_extendable AND not Link_committable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_ext + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) Link_ext_p_nom_lower: description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor" dims: [link] @@ -1804,22 +1999,22 @@ constraints: description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" dims: [scenario, snapshot, process] where: not Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom + expression: Process_p >= Process_p_min_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) Process_fix_p_upper: description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" dims: [scenario, snapshot, process] where: not Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom + expression: Process_p <= Process_p_max_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) Process_ext_p_lower: description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" dims: [scenario, snapshot, process] where: Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom_ext + expression: Process_p >= Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) Process_ext_p_upper: description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" dims: [scenario, snapshot, process] where: Process_p_nom_extendable AND not Process_committable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) Process_ext_p_nom_lower: description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" dims: [process] @@ -1871,12 +2066,12 @@ constraints: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" dims: [scenario, snapshot, generator] where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" dims: [scenario, snapshot, generator] where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_com_transition_start_up: description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" dims: [scenario, snapshot, generator] @@ -1986,7 +2181,7 @@ constraints: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_ext + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) Generator_com_ext_p_upper_big_m: description: "`Generator-com-ext-p-upper-bigM` — off, a unit outputs nothing; on, the big M is no bound" dims: [scenario, snapshot, generator] @@ -2000,7 +2195,7 @@ constraints: where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active expression: >- Generator_p >= - Generator_p_min_pu * Generator_p_nom_ext + Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_big_m * Generator_status - Generator_big_m Generator_com_ext_p_lower_nonneg: description: >- @@ -2018,14 +2213,14 @@ constraints: its minimum of one module, whether the build is fixed or a decision dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_com_mod_p_upper: description: >- `Generator-com-mod-p-upper` — a committed modular unit outputs at most one module's share, whether the build is fixed or a decision dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_status_p_fixed_upper: description: >- `Generator-status-p-fixed-upper` — a status is at most the modules in @@ -2065,16 +2260,88 @@ constraints: dims: [scenario, snapshot, generator] where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active expression: Generator_shut_down <= Generator_n_mod + Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [scenario, generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events + Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) + Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 + Generator_maintcap_upper: + description: >- + `Generator-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_ext - Generator_p_nom_min * (1 - Generator_maintenance) + Generator_maintcap_upper_nommax: + description: "`Generator-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_max * Generator_maintenance + Generator_maintcap_lower_nommax: + description: "`Generator-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity >= Generator_p_nom_ext - Generator_p_nom_max * (1 - Generator_maintenance) + Generator_maintcap_lower_nommin: + description: "`Generator-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active AND Generator_p_nom_min > 0 + expression: Generator_maintenance_capacity >= Generator_p_nom_min * Generator_maintenance + Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_maintenance + Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 + Generator_maint_modstatus_le_status: + description: "`Generator-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_modstatus_le_maint: + description: >- + `Generator-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_p_nom_max / Generator_p_nom_mod * Generator_maintenance + Generator_maint_modstatus_lb: + description: "`Generator-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status >= Generator_status - Generator_p_nom_max / Generator_p_nom_mod * (1 - Generator_maintenance) Link_com_p_lower: description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" dims: [scenario, snapshot, link] where: Link_committable AND not Link_p_nom_extendable AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom * Link_status + expression: Link_p >= Link_p_min_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) Link_com_p_upper: description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" dims: [scenario, snapshot, link] where: Link_committable AND not Link_p_nom_extendable AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom * Link_status + expression: Link_p <= Link_p_max_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) Link_com_transition_start_up: description: "`Link-com-transition-start-up` — turning on is a start, counted against the state the link carried into the snapshot" dims: [scenario, snapshot, link] @@ -2184,7 +2451,7 @@ constraints: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_ext + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) Link_com_ext_p_upper_big_m: description: "`Link-com-ext-p-upper-bigM` — off, a link flows nothing; on, the big M is no bound" dims: [scenario, snapshot, link] @@ -2198,7 +2465,7 @@ constraints: where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active expression: >- Link_p >= - Link_p_min_pu * Link_p_nom_ext + Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_big_m * Link_status - Link_big_m Link_com_ext_p_lower_nonneg: description: >- @@ -2216,14 +2483,14 @@ constraints: its minimum of one module, whether the build is fixed or a decision dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * Link_status + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) Link_com_mod_p_upper: description: >- `Link-com-mod-p-upper` — a committed modular link flows at most one module's share, whether the build is fixed or a decision dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_mod > 0 AND Link_active - expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * Link_status + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) Link_status_p_fixed_upper: description: >- `Link-status-p-fixed-upper` — a status is at most the modules in @@ -2263,16 +2530,88 @@ constraints: dims: [scenario, snapshot, link] where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active expression: Link_shut_down <= Link_n_mod + Link_maint_event_count: + description: "`Link-maint-event-count` — a maintainable link holds its number of maintenance events over the horizon" + dims: [scenario, link] + where: Link_maintainable + expression: sum(Link_maintenance_start, over=snapshot) == Link_maintenance_events + Link_maint_window: + description: >- + `Link-maint-window` — a link is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + expression: Link_maintenance == sum(Link_maintenance_start, by=Link_maintenance_cover, over=start, into=covered) + Link_maint_start_horizon: + description: "`Link-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active AND Link_maintenance_start_blocked + expression: Link_maintenance_start == 0 + Link_maintcap_upper: + description: >- + `Link-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_ext - Link_p_nom_min * (1 - Link_maintenance) + Link_maintcap_upper_nommax: + description: "`Link-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_max * Link_maintenance + Link_maintcap_lower_nommax: + description: "`Link-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity >= Link_p_nom_ext - Link_p_nom_max * (1 - Link_maintenance) + Link_maintcap_lower_nommin: + description: "`Link-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active AND Link_p_nom_min > 0 + expression: Link_maintenance_capacity >= Link_p_nom_min * Link_maintenance + Link_maint_status_le_status: + description: "`Link-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_status_le_maint: + description: "`Link-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_maintenance + Link_maint_status_lb: + description: "`Link-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status >= Link_status + Link_maintenance - 1 + Link_maint_modstatus_le_status: + description: "`Link-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_modstatus_le_maint: + description: >- + `Link-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_p_nom_max / Link_p_nom_mod * Link_maintenance + Link_maint_modstatus_lb: + description: "`Link-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status >= Link_status - Link_p_nom_max / Link_p_nom_mod * (1 - Link_maintenance) Process_com_p_lower: description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" dims: [scenario, snapshot, process] where: Process_committable AND not Process_p_nom_extendable AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom * Process_status + expression: Process_p >= Process_p_min_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) Process_com_p_upper: description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" dims: [scenario, snapshot, process] where: Process_committable AND not Process_p_nom_extendable AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom * Process_status + expression: Process_p <= Process_p_max_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) Process_com_transition_start_up: description: "`Process-com-transition-start-up` — turning on is a start, counted against the state the process carried into the snapshot" dims: [scenario, snapshot, process] @@ -2382,7 +2721,7 @@ constraints: at most what is available of the chosen build, whatever its status dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_ext + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) Process_com_ext_p_upper_big_m: description: "`Process-com-ext-p-upper-bigM` — off, a process does not run; on, the big M is no bound" dims: [scenario, snapshot, process] @@ -2396,7 +2735,7 @@ constraints: where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active expression: >- Process_p >= - Process_p_min_pu * Process_p_nom_ext + Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_big_m * Process_status - Process_big_m Process_com_ext_p_lower_nonneg: description: >- @@ -2414,14 +2753,14 @@ constraints: its minimum of one module, whether the build is fixed or a decision dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_mod > 0 AND Process_active - expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * Process_status + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) Process_com_mod_p_upper: description: >- `Process-com-mod-p-upper` — a committed modular process runs at most one module's share, whether the build is fixed or a decision dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_mod > 0 AND Process_active - expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * Process_status + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) Process_status_p_fixed_upper: description: >- `Process-status-p-fixed-upper` — a status is at most the modules in @@ -2461,6 +2800,78 @@ constraints: dims: [scenario, snapshot, process] where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active expression: Process_shut_down <= Process_n_mod + Process_maint_event_count: + description: "`Process-maint-event-count` — a maintainable process holds its number of maintenance events over the horizon" + dims: [scenario, process] + where: Process_maintainable + expression: sum(Process_maintenance_start, over=snapshot) == Process_maintenance_events + Process_maint_window: + description: >- + `Process-maint-window` — a process is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + expression: Process_maintenance == sum(Process_maintenance_start, by=Process_maintenance_cover, over=start, into=covered) + Process_maint_start_horizon: + description: "`Process-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active AND Process_maintenance_start_blocked + expression: Process_maintenance_start == 0 + Process_maintcap_upper: + description: >- + `Process-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_ext - Process_p_nom_min * (1 - Process_maintenance) + Process_maintcap_upper_nommax: + description: "`Process-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_max * Process_maintenance + Process_maintcap_lower_nommax: + description: "`Process-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity >= Process_p_nom_ext - Process_p_nom_max * (1 - Process_maintenance) + Process_maintcap_lower_nommin: + description: "`Process-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active AND Process_p_nom_min > 0 + expression: Process_maintenance_capacity >= Process_p_nom_min * Process_maintenance + Process_maint_status_le_status: + description: "`Process-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_status_le_maint: + description: "`Process-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_maintenance + Process_maint_status_lb: + description: "`Process-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status >= Process_status + Process_maintenance - 1 + Process_maint_modstatus_le_status: + description: "`Process-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_modstatus_le_maint: + description: >- + `Process-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_p_nom_max / Process_p_nom_mod * Process_maintenance + Process_maint_modstatus_lb: + description: "`Process-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status >= Process_status - Process_p_nom_max / Process_p_nom_mod * (1 - Process_maintenance) Line_fix_s_lower: description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" dims: [scenario, snapshot, line] diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index e2db48d7..6c90cd23 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1184,5 +1184,214 @@ "Store-fix-e-lower": 8, "Store-fix-e-upper": 8 } + }, + "rung_33_maintenance": { + "columns": { + "Generator-maintenance": 8, + "Generator-maintenance_capacity": 4, + "Generator-maintenance_start": 8, + "Generator-p": 20, + "Generator-p_nom": 1, + "Link-maintenance": 8, + "Link-maintenance_capacity": 4, + "Link-maintenance_start": 8, + "Link-p": 12, + "Link-p_nom": 1, + "Process-maintenance": 8, + "Process-maintenance_capacity": 4, + "Process-maintenance_start": 8, + "Process-p": 8, + "Process-p_nom": 1 + }, + "global_constraints": {}, + "objective": 7367.560185185185, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-ext-p-lower": 4, + "Generator-ext-p-upper": 4, + "Generator-ext-p_nom-lower": 1, + "Generator-ext-p_nom-upper": 1, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Generator-maint-event-count": 2, + "Generator-maint-start-horizon": 1, + "Generator-maint-window": 8, + "Generator-maintcap_lower_nommax": 4, + "Generator-maintcap_lower_nommin": 4, + "Generator-maintcap_upper": 4, + "Generator-maintcap_upper_nommax": 4, + "Link-ext-p-lower": 4, + "Link-ext-p-upper": 4, + "Link-ext-p_nom-lower": 1, + "Link-ext-p_nom-upper": 1, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8, + "Link-maint-event-count": 2, + "Link-maint-start-horizon": 1, + "Link-maint-window": 8, + "Link-maintcap_lower_nommax": 4, + "Link-maintcap_lower_nommin": 4, + "Link-maintcap_upper": 4, + "Link-maintcap_upper_nommax": 4, + "Process-ext-p-lower": 4, + "Process-ext-p-upper": 4, + "Process-ext-p_nom-lower": 1, + "Process-ext-p_nom-upper": 1, + "Process-fix-p-lower": 4, + "Process-fix-p-upper": 4, + "Process-maint-event-count": 2, + "Process-maint-start-horizon": 1, + "Process-maint-window": 8, + "Process-maintcap_lower_nommax": 4, + "Process-maintcap_lower_nommin": 4, + "Process-maintcap_upper": 4, + "Process-maintcap_upper_nommax": 4 + } + }, + "rung_34_committable_maintenance": { + "columns": { + "Generator-maintenance": 12, + "Generator-maintenance_capacity": 4, + "Generator-maintenance_start": 12, + "Generator-maintenance_status": 8, + "Generator-n_mod": 1, + "Generator-p": 24, + "Generator-p_nom": 2, + "Generator-shut_down": 12, + "Generator-start_up": 12, + "Generator-status": 12, + "Link-maintenance": 12, + "Link-maintenance_capacity": 4, + "Link-maintenance_start": 12, + "Link-maintenance_status": 8, + "Link-n_mod": 1, + "Link-p": 16, + "Link-p_nom": 2, + "Link-shut_down": 12, + "Link-start_up": 12, + "Link-status": 12, + "Process-maintenance": 12, + "Process-maintenance_capacity": 4, + "Process-maintenance_start": 12, + "Process-maintenance_status": 8, + "Process-n_mod": 1, + "Process-p": 12, + "Process-p_nom": 2, + "Process-shut_down": 12, + "Process-start_up": 12, + "Process-status": 12 + }, + "global_constraints": {}, + "objective": 6420.048611111111, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-com-ext-p-lower": 4, + "Generator-com-ext-p-lower-nonneg": 4, + "Generator-com-ext-p-upper-bigM": 4, + "Generator-com-ext-p-upper-cap": 4, + "Generator-com-mod-p-lower": 4, + "Generator-com-mod-p-upper": 4, + "Generator-com-p-lower": 4, + "Generator-com-p-upper": 4, + "Generator-com-status-min_up_time_must_stay_up": 0, + "Generator-com-transition-shut-down": 12, + "Generator-com-transition-start-up": 12, + "Generator-ext-p_nom-lower": 2, + "Generator-ext-p_nom-upper": 2, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Generator-maint-event-count": 3, + "Generator-maint-modstatus-lb": 4, + "Generator-maint-modstatus-le-maint": 4, + "Generator-maint-modstatus-le-status": 4, + "Generator-maint-start-horizon": 1, + "Generator-maint-status-lb": 4, + "Generator-maint-status-le-maint": 4, + "Generator-maint-status-le-status": 4, + "Generator-maint-window": 12, + "Generator-maintcap_lower_nommax": 4, + "Generator-maintcap_lower_nommin": 4, + "Generator-maintcap_upper": 4, + "Generator-maintcap_upper_nommax": 4, + "Generator-p_nom_modularity": 1, + "Generator-shut_down-p-fixed-upper": 8, + "Generator-shut_down-p_nom-variable-upper": 4, + "Generator-start_up-p-fixed-upper": 8, + "Generator-start_up-p_nom-variable-upper": 4, + "Generator-status-p-fixed-upper": 8, + "Generator-status-p_nom-variable-upper": 4, + "Link-com-ext-p-lower": 4, + "Link-com-ext-p-lower-nonneg": 4, + "Link-com-ext-p-upper-bigM": 4, + "Link-com-ext-p-upper-cap": 4, + "Link-com-mod-p-lower": 4, + "Link-com-mod-p-upper": 4, + "Link-com-p-lower": 4, + "Link-com-p-upper": 4, + "Link-com-status-min_up_time_must_stay_up": 0, + "Link-com-transition-shut-down": 12, + "Link-com-transition-start-up": 12, + "Link-ext-p_nom-lower": 2, + "Link-ext-p_nom-upper": 2, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Link-maint-event-count": 3, + "Link-maint-modstatus-lb": 4, + "Link-maint-modstatus-le-maint": 4, + "Link-maint-modstatus-le-status": 4, + "Link-maint-start-horizon": 1, + "Link-maint-status-lb": 4, + "Link-maint-status-le-maint": 4, + "Link-maint-status-le-status": 4, + "Link-maint-window": 12, + "Link-maintcap_lower_nommax": 4, + "Link-maintcap_lower_nommin": 4, + "Link-maintcap_upper": 4, + "Link-maintcap_upper_nommax": 4, + "Link-p_nom_modularity": 1, + "Link-shut_down-p-fixed-upper": 8, + "Link-shut_down-p_nom-variable-upper": 4, + "Link-start_up-p-fixed-upper": 8, + "Link-start_up-p_nom-variable-upper": 4, + "Link-status-p-fixed-upper": 8, + "Link-status-p_nom-variable-upper": 4, + "Process-com-ext-p-lower": 4, + "Process-com-ext-p-lower-nonneg": 4, + "Process-com-ext-p-upper-bigM": 4, + "Process-com-ext-p-upper-cap": 4, + "Process-com-mod-p-lower": 4, + "Process-com-mod-p-upper": 4, + "Process-com-p-lower": 4, + "Process-com-p-upper": 4, + "Process-com-status-min_up_time_must_stay_up": 0, + "Process-com-transition-shut-down": 12, + "Process-com-transition-start-up": 12, + "Process-ext-p_nom-lower": 2, + "Process-ext-p_nom-upper": 2, + "Process-maint-event-count": 3, + "Process-maint-modstatus-lb": 4, + "Process-maint-modstatus-le-maint": 4, + "Process-maint-modstatus-le-status": 4, + "Process-maint-start-horizon": 1, + "Process-maint-status-lb": 4, + "Process-maint-status-le-maint": 4, + "Process-maint-status-le-status": 4, + "Process-maint-window": 12, + "Process-maintcap_lower_nommax": 4, + "Process-maintcap_lower_nommin": 4, + "Process-maintcap_upper": 4, + "Process-maintcap_upper_nommax": 4, + "Process-p_nom_modularity": 1, + "Process-shut_down-p-fixed-upper": 8, + "Process-shut_down-p_nom-variable-upper": 4, + "Process-start_up-p-fixed-upper": 8, + "Process-start_up-p_nom-variable-upper": 4, + "Process-status-p-fixed-upper": 8, + "Process-status-p_nom-variable-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_33_maintenance.py b/examples/references/pypsa/rung_33_maintenance.py new file mode 100644 index 00000000..5a5ba805 --- /dev/null +++ b/examples/references/pypsa/rung_33_maintenance.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 33: maintenance — a fixed and an extendable generator, link and process, each taken off for events that the generator weightings time.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus and six maintainable components, fixed and extendable, with a dear peaker to cover them.""" + n = spine.build() + n.add('Bus', 'east') + n.add('Generator', 'hydro', bus='north', p_nom=50, marginal_cost=2, maintainable=True, maintenance_duration=3) + n.add( + 'Generator', + 'wind_ext', + bus='south', + p_nom_extendable=True, + p_nom_min=10, + p_nom_max=60, + capital_cost=20, + marginal_cost=1, + p_max_pu=[0.9, 0.6, 0.8, 0.7], + maintainable=True, + maintenance_duration=2, + maintenance_pu=0.5, + ) + n.add( + 'Link', + 'tie_fix', + bus0='north', + bus1='east', + p_nom=30, + efficiency=0.95, + maintainable=True, + maintenance_duration=1, + maintenance_events=2, + ) + n.add( + 'Link', + 'tie_ext', + bus0='south', + bus1='east', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=40, + capital_cost=4, + efficiency=0.9, + p_min_pu=-1, + maintainable=True, + maintenance_duration=3, + ) + n.add( + 'Process', + 'conv_fix', + bus0='north', + bus1='east', + rate0=-1.25, + p_nom=25, + maintainable=True, + maintenance_duration=3, + maintenance_pu=0.6, + ) + n.add( + 'Process', + 'conv_ext', + bus0='south', + bus1='east', + rate0=-1.25, + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=30, + capital_cost=3, + maintainable=True, + maintenance_duration=1, + ) + n.add('Generator', 'east_peak', bus='east', p_nom=100, marginal_cost=60) + n.add('Load', 'east_load', bus='east', p_set=[50, 60, 40, 55]) + return n diff --git a/examples/references/pypsa/rung_34_committable_maintenance.py b/examples/references/pypsa/rung_34_committable_maintenance.py new file mode 100644 index 00000000..f78b57d1 --- /dev/null +++ b/examples/references/pypsa/rung_34_committable_maintenance.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 34: maintenance of committable units — a fixed, an extendable and a modular committable generator, link and process, each taken off for one event.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus an east bus and nine maintainable committable components, with a dear peaker to cover them.""" + n = spine.build() + n.add('Bus', 'east') + committable = {'committable': True, 'maintainable': True} + n.add( + 'Generator', + 'unit', + bus='north', + p_nom=60, + p_min_pu=0.3, + marginal_cost=5, + start_up_cost=10, + maintenance_duration=2, + **committable, + ) + n.add( + 'Generator', + 'unit_ext', + bus='south', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=50, + capital_cost=10, + p_min_pu=0.2, + marginal_cost=4, + maintenance_duration=3, + maintenance_pu=0.5, + **committable, + ) + n.add( + 'Generator', + 'unit_mod', + bus='south', + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=30, + capital_cost=8, + p_min_pu=0.5, + marginal_cost=3, + maintenance_duration=1, + **committable, + ) + for component, ports in (('Link', {}), ('Process', {'rate0': -1.25})): + n.add( + component, + f'{component.lower()}_unit', + bus0='north', + bus1='east', + p_nom=40, + p_min_pu=0.2, + marginal_cost=1, + maintenance_duration=2, + **ports, + **committable, + ) + n.add( + component, + f'{component.lower()}_ext', + bus0='south', + bus1='east', + p_nom_extendable=True, + p_nom_min=5, + p_nom_max=30, + capital_cost=5, + p_min_pu=0.2, + maintenance_duration=3, + maintenance_pu=0.5, + **ports, + **committable, + ) + n.add( + component, + f'{component.lower()}_mod', + bus0='north', + bus1='east', + p_nom_extendable=True, + p_nom_mod=10, + p_nom_max=20, + capital_cost=3, + p_min_pu=0.5, + maintenance_duration=1, + **ports, + **committable, + ) + n.add('Generator', 'east_peak', bus='east', p_nom=100, marginal_cost=60) + n.add('Load', 'east_load', bus='east', p_set=[50, 60, 40, 55]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index d7ecaa05..eeb940f5 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -308,3 +308,27 @@ names: Transformer_first_active: '\mathrm{new}^{\sigma}' Carrier_max_growth: '\overline{\Delta}' Carrier_max_relative_growth: '\mathrm{r}' + Generator_maintainable: '\mathrm{mnt}' + Link_maintainable: '\mathrm{mnt}^{f}' + Process_maintainable: '\mathrm{mnt}^{z}' + Generator_maintenance_pu: '\gamma' + Link_maintenance_pu: '\gamma^{f}' + Process_maintenance_pu: '\gamma^{z}' + Generator_maintenance_events: '\mathrm{n}^{\mathrm{mnt}}' + Link_maintenance_events: '\mathrm{n}^{f,\mathrm{mnt}}' + Process_maintenance_events: '\mathrm{n}^{z,\mathrm{mnt}}' + Generator_maintenance_start_blocked: '\mathrm{blk}' + Link_maintenance_start_blocked: '\mathrm{blk}^{f}' + Process_maintenance_start_blocked: '\mathrm{blk}^{z}' + Generator_maintenance: '\mu' + Link_maintenance: '\mu^{f}' + Process_maintenance: '\mu^{z}' + Generator_maintenance_start: '\mu^{\mathrm{up}}' + Link_maintenance_start: '\mu^{f,\mathrm{up}}' + Process_maintenance_start: '\mu^{z,\mathrm{up}}' + Generator_maintenance_capacity: '\mu^{\mathrm{nom}}' + Link_maintenance_capacity: '\mu^{f,\mathrm{nom}}' + Process_maintenance_capacity: '\mu^{z,\mathrm{nom}}' + Generator_maintenance_status: '\mu^{u}' + Link_maintenance_status: '\mu^{f,u}' + Process_maintenance_status: '\mu^{z,u}' From eec71d2bbefe88f82584db2cf350eb63330c6847 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 13:02:36 +0200 Subject: [PATCH 20/75] docs(pypsa): the spec states what maintenance and late-opening storage assume of the data --- docs/examples/pypsa.md | 400 +++++++++++++++++++++++++++++++++++- examples/pypsa.yaml | 189 +++++++++++++++++ examples/symbols/pypsa.yaml | 3 + 3 files changed, 587 insertions(+), 5 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 9988d174..454dd7c3 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -2566,7 +2566,14 @@ horizon, the file states the same rows with two data-prep parameters. The opening snapshot past the horizon's first is `{c}_opens_late`. The number of snapshots the storage does not stand in is `{c}_inactive_snapshots`, and a cyclic storage reaches back that many snapshots further. A plain run feeds -false and zero, so the rows collapse to the standard ones. +false and zero, so the rows collapse to the standard ones. The assumptions +[`StorageUnit_stands_in_one_run`](#storageunit_stands_in_one_run), +[`-opens_late_only_where_it_opens`](#storageunit_opens_late_only_where_it_opens) +and [`-opens_late_where_it_opens`](#storageunit_opens_late_where_it_opens), and +the `Store` ones, state the run and the opening snapshot. No +assumption ties `{c}_inactive_snapshots` to the count of inactive snapshots, +because a `count` compares against a whole number and not against a +parameter. The rung builds a cyclic storage unit and a store with an initial level of 5 in 2030, and a cyclic store that retires after 2020. The earlier file read the @@ -2907,8 +2914,9 @@ def build(): ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language -gap; each is a data check not made yet, and where it should live — language, -data prep, or harness — is one open question. Line numbers are pinned pypsa +gap. The maintenance checks are assumptions of the file, which the consumer +that binds the data runs. Each other one is a data check not made yet, and +where it should live — language, data prep, or harness — is one open question. Line numbers are pinned pypsa 1.3.0, the version the records above are from. | PyPSA raises | on | here | note | @@ -2920,8 +2928,8 @@ data prep, or harness — is one open question. Line numbers are pinned pypsa | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | | `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | -| `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | data prep, at `*_maintenance_cover` and `*_maintenance_start_blocked`; an infinite coefficient in the `maintcap` rows | | -| nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | an infinite coefficient in the `maint-modstatus` rows | | +| `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | assumed: [`Generator_maintenance_events_positive`](#generator_maintenance_events_positive), [`-duration_positive`](#generator_maintenance_duration_positive), [`-duration_fits_the_horizon`](#generator_maintenance_duration_fits_the_horizon), [`-events_fit_the_horizon`](#generator_maintenance_events_fit_the_horizon), [`-build_cap_is_finite`](#generator_maintenance_build_cap_is_finite), and the `Link` and `Process` ones | | +| nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | assumed: [`Generator_maintenance_module_count_is_finite`](#generator_maintenance_module_count_is_finite), and the `Link` and `Process` ones | | Duals and solutions are read back by the harness on the lpspec side: `marginal_price` is the balance dual over `w_objective`, `mu_upper` the @@ -2987,6 +2995,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon | | $`\gamma`$ | `Generator_maintenance_pu` over $`\mathcal{G}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\mathcal{G}`$ — how many maintenance events the horizon holds | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit | @@ -3017,6 +3026,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon | | $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\mathcal{L}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\mathcal{L}`$ — how many maintenance events the horizon holds | +| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | @@ -3051,6 +3061,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon | | $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\mathcal{J}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\mathcal{J}`$ — how many maintenance events the horizon holds | +| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | @@ -8443,6 +8454,385 @@ a_{\xi} \ge 0 \qquad \forall\, \xi \in \Xi ```math CVaR \in \mathbb{R} ``` + +### `Generator_maintenance_events_positive` + +```yaml +Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) +``` + +```math +\mathrm{n}^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_duration_positive` + +```yaml +Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_duration_fits_the_horizon` + +```yaml +Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_events_fit_the_horizon` + +```yaml +Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} \cdot \mathrm{n}^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_build_cap_is_finite` + +```yaml +Generator_maintenance_build_cap_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) +``` + +```math +\overline{\mathrm{p}}^{\mathrm{nom}}_{g} < \infty \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} +``` + +### `Generator_maintenance_module_count_is_finite` + +```yaml +Generator_maintenance_module_count_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +```math +\overline{\mathrm{p}}^{\mathrm{nom}}_{g} < \infty \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +``` + +### `Link_maintenance_events_positive` + +```yaml +Link_maintenance_events_positive: + holds: "Link_maintenance_events > 0" + where: "Link_maintainable" + description: >- + a maintainable link with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) +``` + +```math +\mathrm{n}^{f,\mathrm{mnt}}_{l} > 0 \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +### `Link_maintenance_duration_positive` + +```yaml +Link_maintenance_duration_positive: + holds: "Link_maintenance_duration > 0" + where: "Link_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) +``` + +```math +\tau^{f,\mathrm{mnt}}_{l} > 0 \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +### `Link_maintenance_duration_fits_the_horizon` + +```yaml +Link_maintenance_duration_fits_the_horizon: + holds: "Link_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) +``` + +```math +\tau^{f,\mathrm{mnt}}_{l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +### `Link_maintenance_events_fit_the_horizon` + +```yaml +Link_maintenance_events_fit_the_horizon: + holds: "Link_maintenance_duration * Link_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) +``` + +```math +\tau^{f,\mathrm{mnt}}_{l} \cdot \mathrm{n}^{f,\mathrm{mnt}}_{l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +### `Link_maintenance_build_cap_is_finite` + +```yaml +Link_maintenance_build_cap_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) +``` + +```math +\overline{\mathrm{f}}^{\mathrm{nom}}_{l} < \infty \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} +``` + +### `Link_maintenance_module_count_is_finite` + +```yaml +Link_maintenance_module_count_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +```math +\overline{\mathrm{f}}^{\mathrm{nom}}_{l} < \infty \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + +### `Process_maintenance_events_positive` + +```yaml +Process_maintenance_events_positive: + holds: "Process_maintenance_events > 0" + where: "Process_maintainable" + description: >- + a maintainable process with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) +``` + +```math +\mathrm{n}^{z,\mathrm{mnt}}_{j} > 0 \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +### `Process_maintenance_duration_positive` + +```yaml +Process_maintenance_duration_positive: + holds: "Process_maintenance_duration > 0" + where: "Process_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) +``` + +```math +\tau^{z,\mathrm{mnt}}_{j} > 0 \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +### `Process_maintenance_duration_fits_the_horizon` + +```yaml +Process_maintenance_duration_fits_the_horizon: + holds: "Process_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) +``` + +```math +\tau^{z,\mathrm{mnt}}_{j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +### `Process_maintenance_events_fit_the_horizon` + +```yaml +Process_maintenance_events_fit_the_horizon: + holds: "Process_maintenance_duration * Process_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) +``` + +```math +\tau^{z,\mathrm{mnt}}_{j} \cdot \mathrm{n}^{z,\mathrm{mnt}}_{j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +### `Process_maintenance_build_cap_is_finite` + +```yaml +Process_maintenance_build_cap_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) +``` + +```math +\overline{\mathrm{z}}^{\mathrm{nom}}_{j} < \infty \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} +``` + +### `Process_maintenance_module_count_is_finite` + +```yaml +Process_maintenance_module_count_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +```math +\overline{\mathrm{z}}^{\mathrm{nom}}_{j} < \infty \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + +### `StorageUnit_stands_in_one_run` + +```yaml +StorageUnit_stands_in_one_run: + holds: "count(StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a storage unit stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic storage unit reaches back + `StorageUnit_inactive_snapshots` further to the last of them +``` + +```math +\lvert \{ t \in \mathcal{T} \,:\, \mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \} \rvert \le 1 \qquad \forall\, s \in \mathcal{S} +``` + +### `StorageUnit_opens_late_only_where_it_opens` + +```yaml +StorageUnit_opens_late_only_where_it_opens: + holds: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "StorageUnit_opens_late" + description: >- + `StorageUnit_opens_late` marks the first snapshot the storage unit stands in, past + the first of the horizon, and no other +``` + +```math +\mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \wedge \mathrm{pos}(t) > 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{open}_{t,s} +``` + +### `StorageUnit_opens_late_where_it_opens` + +```yaml +StorageUnit_opens_late_where_it_opens: + holds: "StorageUnit_opens_late" + where: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a storage unit that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `StorageUnit_opens_late` marks the snapshot +``` + +```math +\mathrm{open}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \wedge \mathrm{pos}(t) > 0 +``` + +### `Store_stands_in_one_run` + +```yaml +Store_stands_in_one_run: + holds: "count(Store_active AND NOT shift(Store_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a store stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic store reaches back + `Store_inactive_snapshots` further to the last of them +``` + +```math +\lvert \{ t \in \mathcal{T} \,:\, \mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \} \rvert \le 1 \qquad \forall\, v \in \mathcal{V} +``` + +### `Store_opens_late_only_where_it_opens` + +```yaml +Store_opens_late_only_where_it_opens: + holds: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "Store_opens_late" + description: >- + `Store_opens_late` marks the first snapshot the store stands in, past + the first of the horizon, and no other +``` + +```math +\mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \wedge \mathrm{pos}(t) > 0 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{open}^{e}_{t,v} +``` + +### `Store_opens_late_where_it_opens` + +```yaml +Store_opens_late_where_it_opens: + holds: "Store_opens_late" + where: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a store that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `Store_opens_late` marks the snapshot +``` + +```math +\mathrm{open}^{e}_{t,v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \wedge \mathrm{pos}(t) > 0 +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index a400d145..a56f8a86 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -174,6 +174,14 @@ parameters: description: how many maintenance events the horizon holds dims: [generator] dtype: int + Generator_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the generator is not + maintainable. No row reads it: data prep turns it into + `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [generator] Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -302,6 +310,14 @@ parameters: description: how many maintenance events the horizon holds dims: [link] dtype: int + Link_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the link is not + maintainable. No row reads it: data prep turns it into + `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [link] Link_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -444,6 +460,14 @@ parameters: description: how many maintenance events the horizon holds dims: [process] dtype: int + Process_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the process is not + maintainable. No row reads it: data prep turns it into + `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [process] Process_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -3434,6 +3458,171 @@ constraints: dims: [] expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR +assumptions: + Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Generator_maintenance_build_cap_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Generator_maintenance_module_count_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) + Link_maintenance_events_positive: + holds: "Link_maintenance_events > 0" + where: "Link_maintainable" + description: >- + a maintainable link with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Link_maintenance_duration_positive: + holds: "Link_maintenance_duration > 0" + where: "Link_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Link_maintenance_duration_fits_the_horizon: + holds: "Link_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Link_maintenance_events_fit_the_horizon: + holds: "Link_maintenance_duration * Link_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Link_maintenance_build_cap_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Link_maintenance_module_count_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) + Process_maintenance_events_positive: + holds: "Process_maintenance_events > 0" + where: "Process_maintainable" + description: >- + a maintainable process with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Process_maintenance_duration_positive: + holds: "Process_maintenance_duration > 0" + where: "Process_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Process_maintenance_duration_fits_the_horizon: + holds: "Process_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Process_maintenance_events_fit_the_horizon: + holds: "Process_maintenance_duration * Process_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Process_maintenance_build_cap_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Process_maintenance_module_count_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) + StorageUnit_stands_in_one_run: + holds: "count(StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a storage unit stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic storage unit reaches back + `StorageUnit_inactive_snapshots` further to the last of them + StorageUnit_opens_late_only_where_it_opens: + holds: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "StorageUnit_opens_late" + description: >- + `StorageUnit_opens_late` marks the first snapshot the storage unit stands in, past + the first of the horizon, and no other + StorageUnit_opens_late_where_it_opens: + holds: "StorageUnit_opens_late" + where: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a storage unit that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `StorageUnit_opens_late` marks the snapshot + Store_stands_in_one_run: + holds: "count(Store_active AND NOT shift(Store_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a store stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic store reaches back + `Store_inactive_snapshots` further to the last of them + Store_opens_late_only_where_it_opens: + holds: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "Store_opens_late" + description: >- + `Store_opens_late` marks the first snapshot the store stands in, past + the first of the horizon, and no other + Store_opens_late_where_it_opens: + holds: "Store_opens_late" + where: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a store that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `Store_opens_late` marks the snapshot + objective: sense: minimize description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index eeb940f5..299b8d13 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -317,6 +317,9 @@ names: Generator_maintenance_events: '\mathrm{n}^{\mathrm{mnt}}' Link_maintenance_events: '\mathrm{n}^{f,\mathrm{mnt}}' Process_maintenance_events: '\mathrm{n}^{z,\mathrm{mnt}}' + Generator_maintenance_duration: '\tau^{\mathrm{mnt}}' + Link_maintenance_duration: '\tau^{f,\mathrm{mnt}}' + Process_maintenance_duration: '\tau^{z,\mathrm{mnt}}' Generator_maintenance_start_blocked: '\mathrm{blk}' Link_maintenance_start_blocked: '\mathrm{blk}^{f}' Process_maintenance_start_blocked: '\mathrm{blk}^{z}' From 64b50d010f825b58155392f4070430cf05fe62cb Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 13:28:38 +0200 Subject: [PATCH 21/75] docs(pypsa): a growth limit counts no transformer, as pypsa does --- docs/examples/pypsa.md | 16 +++++++--------- examples/pypsa.yaml | 14 +++----------- examples/symbols/pypsa.yaml | 1 - 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 454dd7c3..db1ce623 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -2956,13 +2956,13 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | | $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | | $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | | $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | | $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | -| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Transformer\_carrier}: \mathcal{M} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | #### Parameters @@ -3088,7 +3088,6 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{\sigma}`$ | `Transformer_first_active` over $`\mathcal{Y} \times \mathcal{M}`$ — one in the first period a transformer stands in, zero elsewhere — the spec extends the carrier growth limit to transformers, which PyPSA does not, so PyPSA has no counterpart, data prep | | $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top | | $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | @@ -3297,7 +3296,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | -| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. PyPSA sums the components that carry a carrier attribute; the transformer term is the spec's own extension, since PyPSA gives a transformer no carrier | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | @@ -8098,9 +8097,9 @@ scenario_opex: Carrier_additions: description: >- what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. PyPSA sums - the components that carry a carrier attribute; the transformer term is the - spec's own extension, since PyPSA gives a transformer no carrier + carrier, counting each build in the first period it stands in. Like PyPSA, + it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) @@ -8108,11 +8107,10 @@ Carrier_additions: + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) - + sum(Transformer_s_nom_ext * Transformer_first_active, by=Transformer_carrier, over=transformer, into=carrier) ``` ```math -\mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_carrier}(m) = i} \Sigma_{m} \cdot \mathrm{new}^{\sigma}_{y,m} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +\mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} ``` ### `Line_s_monitored` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index a56f8a86..5a454bc9 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -558,9 +558,6 @@ parameters: Process_first_active: description: one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep dims: [period, process] - Transformer_first_active: - description: one in the first period a transformer stands in, zero elsewhere — the spec extends the carrier growth limit to transformers, which PyPSA does not, so PyPSA has no counterpart, data prep - dims: [period, transformer] Carrier_max_growth: description: most capacity of a carrier that may be added in a period; no value means no limit dims: [carrier] @@ -1073,10 +1070,6 @@ relations: description: the bus a store sits on key: store values: bus - Transformer_carrier: - description: the carrier a transformer carries - key: transformer - values: carrier Transformer_bus0: description: the bus a transformer's flow is measured at key: transformer @@ -1903,9 +1896,9 @@ expressions: Carrier_additions: description: >- what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. PyPSA sums - the components that carry a carrier attribute; the transformer term is the - spec's own extension, since PyPSA gives a transformer no carrier + carrier, counting each build in the first period it stands in. Like PyPSA, + it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) @@ -1913,7 +1906,6 @@ expressions: + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) - + sum(Transformer_s_nom_ext * Transformer_first_active, by=Transformer_carrier, over=transformer, into=carrier) Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 299b8d13..f8475175 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -305,7 +305,6 @@ names: Store_first_active: '\mathrm{new}^{e}' Line_first_active: '\mathrm{new}^{s}' Process_first_active: '\mathrm{new}^{z}' - Transformer_first_active: '\mathrm{new}^{\sigma}' Carrier_max_growth: '\overline{\Delta}' Carrier_max_relative_growth: '\mathrm{r}' Generator_maintainable: '\mathrm{mnt}' From 76c5c249071b9b330bcd8733a944468c35d6bf4f Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 13:56:29 +0200 Subject: [PATCH 22/75] docs(pypsa): a global constraint may count one investment period, weighted by its years --- docs/examples/pypsa.md | 367 ++++++++++++++++-- examples/pypsa.yaml | 192 +++++++-- examples/references/pypsa/references.json | 45 +++ .../rung_35_period_global_constraints.py | 74 ++++ examples/symbols/pypsa.yaml | 7 +- tests/fixtures/pypsa_standard_shape.yaml | 2 - 6 files changed, 627 insertions(+), 60 deletions(-) create mode 100644 examples/references/pypsa/rung_35_period_global_constraints.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index db1ce623..31831d22 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -414,8 +414,8 @@ each type is three blocks by sense. | PyPSA type | status | note | | ------------------------------------- | ----------- | ------------------------------------------------- | -| [`primary_energy`](#primary_energy) | split | a block per sense — sense as data is beyond #70; carrier weights and the horizon-end charge read are prep | -| [`operational_limit`](#operational_limit) | split | a block per sense | +| [`primary_energy`](#primary_energy) | split | a block per sense — sense as data is beyond #70; carrier weights are prep; one period in rung 35 | +| [`operational_limit`](#operational_limit) | split | a block per sense; one period in rung 35 | | [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) | split | a block per sense; membership from PyPSA's carrier string is prep | | [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) | split | a block per sense | | [`tech_capacity_expansion_limit`](#tech_capacity_expansion_limit) | split | a block per sense | @@ -1360,6 +1360,7 @@ own dimensions. | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) for storage built in a later period or retired early | done | rung 32 | +| [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit) for one investment period, weighted by period years | done | rung 35 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. @@ -2911,6 +2912,120 @@ def build(): +### Rung 35 — a global constraint for one investment period + +`n.optimize(multi_investment_periods=True)` with `primary_energy` and +`operational_limit` rows that name an `investment_period`. PyPSA sums such a +row over the snapshots of that period only, and over the whole horizon where +the row names none (`global_constraints.py:373-378`, `600-606`). Each snapshot +counts with its generator weighting times the `years` weighting of its period +(`:326`, `:615`). A storage unit or store that reopens per period closes at the +last snapshot of each counted period, weighted by that period's years +(`:474-477`, `:673-676`). One that carries its level across periods closes +once, at the last counted snapshot (`:459-470`, `:658-668`). The file states +the counted snapshots as `GlobalConstraint_counts_snapshot`, data prep, and +the years as `period_weight_years`. A plain run feeds all true and one, so the +rows collapse to the standard ones. + +The rung caps CO2 in 2030 alone, caps it again over the horizon, and limits a +hydro carrier with a per-period storage unit and store in 2020. The years +weightings are 5 and 10. With the same network and no `investment_period`, +PyPSA solves to `10675.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit) over one investment period | done | `GlobalConstraint_energy_weight` is zero outside the counted snapshots, and the years weigh each snapshot | +| the closing level of storage in those rows | done | `StorageUnit_closing_weight`, `Store_closing_weight`: each counted period's last snapshot where the storage reopens per period, the last counted snapshot otherwise | +| a `primary_energy` row for one period over storage that reopens per period | refused, as PyPSA | assumed: [`StorageUnit_primary_energy_per_period_closes_over_the_horizon`](#storageunit_primary_energy_per_period_closes_over_the_horizon), and the `Store` one | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `4886.764705882353`, 155 rows. + +
+The network, as PyPSA code + +`rung_35_period_global_constraints.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 35: a global constraint for one investment period — a CO2 cap on 2030 alone, one over the horizon, and a 2020 limit on a carrier with storage that reopens per period.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods of unequal years, two emitting units, a hydro carrier with a unit and storage.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [5.0, 10.0] + n.snapshot_weightings['generators'] = [1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0] + n.add('Carrier', 'coal35', co2_emissions=1.0) + n.add('Carrier', 'gas35', co2_emissions=0.4) + n.add('Carrier', 'hydro35') + n.add('Bus', 'hub') + n.add('Generator', 'coal35', bus='hub', carrier='coal35', p_nom=100, marginal_cost=10, efficiency=0.4) + n.add('Generator', 'gas35', bus='hub', carrier='gas35', p_nom=100, marginal_cost=30, efficiency=0.5) + n.add('Generator', 'clean35', bus='hub', p_nom=200, marginal_cost=60) + n.add('Generator', 'river35', bus='hub', carrier='hydro35', p_nom=30, marginal_cost=5) + n.add( + 'StorageUnit', + 'dam35', + bus='hub', + carrier='hydro35', + p_nom=15, + max_hours=4, + state_of_charge_initial=20, + state_of_charge_initial_per_period=True, + ) + n.add('Store', 'pond35', bus='hub', carrier='hydro35', e_nom=30, e_initial=10, e_initial_per_period=True) + n.add('Load', 'town35', bus='hub', p_set=[60, 80, 70, 50, 90, 110, 100, 80]) + n.add( + 'GlobalConstraint', + 'co2_2030', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=3500, + investment_period=2030, + ) + n.add( + 'GlobalConstraint', + 'co2_all', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=6000, + ) + n.add( + 'GlobalConstraint', + 'hydro_2020', + type='operational_limit', + carrier_attribute='hydro35', + sense='<=', + constant=600, + investment_period=2020, + ) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -2924,7 +3039,9 @@ where it should live — language, data prep, or harness — is one open questio | `ValueError`, `constraints.py:1850` | fixed modular `p_nom` not a multiple of `p_nom_mod` | a fractional module cap | X1 | | `ValueError`, `constraints.py:1557` | load on a bus with nothing attached | row not built, unserved | X2 | | `ValueError`, `optimize.py:436` | no component carries a cost | feasibility problem | X3 | -| `NotImplementedError`, `global_constraints.py:457` | depletion with period weightings `!= 1` | out | | +| `NotImplementedError`, `global_constraints.py:457`, `:509`, `:656`, `:704` | storage that carries its level across periods in a `primary_energy` or `operational_limit` row, with period `years` `!= 1` | assumed: [`StorageUnit_primary_energy_carried_over_has_unit_years`](#storageunit_primary_energy_carried_over_has_unit_years), [`StorageUnit_operational_limit_carried_over_has_unit_years`](#storageunit_operational_limit_carried_over_has_unit_years), and the `Store` ones | | +| `KeyError`, `global_constraints.py:474`, `:526` | a `primary_energy` row for one period over storage that reopens per period | assumed: [`StorageUnit_primary_energy_per_period_closes_over_the_horizon`](#storageunit_primary_energy_per_period_closes_over_the_horizon), and the `Store` one | | +| `UnboundLocalError`, `global_constraints.py:375`, `:602` | a `primary_energy` or `operational_limit` row that names an `investment_period` without `multi_investment_periods` | data prep, at `GlobalConstraint_counts_snapshot` | | | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | | `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | @@ -3068,6 +3185,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | | $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | +| $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.years` — what a period's energy weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | | $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{on}^{h}`$ | `StorageUnit_active` over $`\mathcal{T} \times \mathcal{S}`$ — whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep | @@ -3180,24 +3298,24 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{I}`$ — which way the row binds — `<=`, `>=` or `==` | -| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, a non-extendable build — is folded in here by data prep | -| $`\mathrm{last}`$ | `snapshot_is_last` over $`\mathcal{T}`$ — one at the horizon's last snapshot, zero elsewhere — data prep, how an expression reads a final level | +| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | | $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | | $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | | $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | | $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | | $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | | $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | -| $`\mathrm{len}`$ | `Line_volume_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set — data prep; a line outside it has no row | -| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set — data prep; a link outside it has no row | -| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set — data prep; a line outside it has no row | -| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set — data prep; a link outside it has no row | -| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row | -| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables @@ -3290,8 +3408,12 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy, less the charge left in weighted storage at the horizon's end; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{I}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\mathcal{I} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | +| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | +| $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{w}^{e}`$ | `Store_closing_weight` over $`\mathcal{I} \times \mathcal{T} \times \mathcal{V}`$ — what the energy a store holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{I}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | @@ -3313,6 +3435,8 @@ $`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own ord $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + ### Objective ```yaml @@ -7978,22 +8102,108 @@ Process_output_arrival: \overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} ``` +### `GlobalConstraint_energy_weight` + +```yaml +GlobalConstraint_energy_weight: + description: >- + what one unit of power at a snapshot counts for in a row — the + generator weighting times the years of the snapshot's period, where the + row counts the snapshot, and nothing where it does not + dims: [global_constraint, snapshot] + cases: + counted: + when: GlobalConstraint_counts_snapshot + expression: snapshot_weightings_generators * at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + otherwise: 0 +``` + +```math +\mathit{w}^{\mathrm{gc}}_{i,t} = \begin{cases} \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{in}_{i,t} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T} +``` + +### `GlobalConstraint_snapshot_closes` + +```yaml +GlobalConstraint_snapshot_closes: + description: one at the last snapshot a row counts, and zero elsewhere + dims: [global_constraint, snapshot] + cases: + last_counted: + when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) + expression: 1 + otherwise: 0 +``` + +```math +\mathit{last}_{i,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{i,t} \wedge \neg \mathrm{in}_{i,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T} +``` + +### `StorageUnit_closing_weight` + +```yaml +StorageUnit_closing_weight: + description: >- + what the charge a unit holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the unit reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [global_constraint, snapshot, storage_unit] + cases: + per_period: + when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT StorageUnit_state_of_charge_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 +``` + +```math +\mathit{w}^{h}_{i,t,s} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}_{s} \wedge \mathrm{in}_{i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{i,t} & \text{if } \neg \mathrm{reset}_{s} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T},\ s \in \mathcal{S} +``` + +### `Store_closing_weight` + +```yaml +Store_closing_weight: + description: >- + what the energy a store holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the store reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [global_constraint, snapshot, store] + cases: + per_period: + when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT Store_e_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 +``` + +```math +\mathit{w}^{e}_{i,t,v} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}^{e}_{v} \wedge \mathrm{in}_{i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{i,t} & \text{if } \neg \mathrm{reset}^{e}_{v} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T},\ v \in \mathcal{V} +``` + ### `primary_energy` ```yaml primary_energy: description: >- - what a `primary_energy` row totals — weighted generator energy, less - the charge left in weighted storage at the horizon's end; the initial - charge it is compared against is folded into the row's constant + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant expression: >- - sum(sum(Generator_p * snapshot_weightings_generators * Generator_primary_energy_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * snapshot_is_last * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * snapshot_is_last * Store_primary_energy_weight, over=snapshot), over=store) + sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_primary_energy_weight, over=snapshot), over=generator) + - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + - sum(sum(Store_e * Store_closing_weight * Store_primary_energy_weight, over=snapshot), over=store) ``` ```math -\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{a}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{a}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{i,t} \cdot \mathrm{a}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{i,t,s} \cdot \mathrm{a}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{i,t,v} \cdot \mathrm{a}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `operational_limit` @@ -8002,16 +8212,17 @@ primary_energy: operational_limit: description: >- what an `operational_limit` row totals — the weighted energy its - generators deliver, plus what its non-cyclic storage draws down; the - initial charge it draws from is folded into the row's constant + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant expression: >- - sum(sum(Generator_p * snapshot_weightings_generators * Generator_operational_limit_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * snapshot_is_last * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * snapshot_is_last * Store_operational_limit_weight, over=snapshot), over=store) + sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_operational_limit_weight, over=snapshot), over=generator) + - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + - sum(sum(Store_e * Store_closing_weight * Store_operational_limit_weight, over=snapshot), over=store) ``` ```math -\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{b}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathrm{last}_{t} \cdot \mathrm{b}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{i,t} \cdot \mathrm{b}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{i,t,s} \cdot \mathrm{b}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{i,t,v} \cdot \mathrm{b}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_volume_expansion` @@ -8831,6 +9042,102 @@ Store_opens_late_where_it_opens: ```math \mathrm{open}^{e}_{t,v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \wedge \mathrm{pos}(t) > 0 ``` + +### `StorageUnit_primary_energy_per_period_closes_over_the_horizon` + +```yaml +StorageUnit_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "StorageUnit_primary_energy_weight AND StorageUnit_state_of_charge_initial_per_period" + description: >- + PyPSA reads the closing charge of a unit that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:474`) +``` + +```math +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{i,t} \} \rvert = 0 \qquad \forall\, s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{a}^{h}_{i,s} \text{ is defined} \wedge \mathrm{reset}_{s} +``` + +### `StorageUnit_primary_energy_carried_over_has_unit_years` + +```yaml +StorageUnit_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "StorageUnit_primary_energy_weight AND NOT StorageUnit_state_of_charge_initial_per_period" + description: >- + a unit that carries its charge from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:448`) +``` + +```math +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, s \in \mathcal{S},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{h}_{i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{s} +``` + +### `StorageUnit_operational_limit_carried_over_has_unit_years` + +```yaml +StorageUnit_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "StorageUnit_operational_limit_weight AND NOT StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:647`) +``` + +```math +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{b}^{h}_{i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{s} \wedge \mathrm{in}_{i,t} +``` + +### `Store_primary_energy_per_period_closes_over_the_horizon` + +```yaml +Store_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "Store_primary_energy_weight AND Store_e_initial_per_period" + description: >- + PyPSA reads the closing energy of a store that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:526`) +``` + +```math +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{i,t} \} \rvert = 0 \qquad \forall\, v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{a}^{e}_{i,v} \text{ is defined} \wedge \mathrm{reset}^{e}_{v} +``` + +### `Store_primary_energy_carried_over_has_unit_years` + +```yaml +Store_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "Store_primary_energy_weight AND NOT Store_e_initial_per_period" + description: >- + a store that carries its energy from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:500`) +``` + +```math +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, v \in \mathcal{V},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{e}_{i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{v} +``` + +### `Store_operational_limit_carried_over_has_unit_years` + +```yaml +Store_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "Store_operational_limit_weight AND NOT Store_e_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:695`) +``` + +```math +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{b}^{e}_{i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \mathrm{in}_{i,t} +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 5a454bc9..0b26fd25 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -491,6 +491,12 @@ parameters: period_weight_objective: description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs dims: [period] + period_weight_years: + description: >- + PyPSA's `investment_period_weightings.years` — what a period's energy + weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it + only under `multi_investment_periods`, so data prep feeds one otherwise + dims: [period] Generator_active: description: whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep dims: [snapshot, generator] @@ -930,13 +936,20 @@ parameters: GlobalConstraint_constant: description: >- the constant the total is held against; what a variable cannot carry — - an initial charge, a non-extendable build — is folded in here by data - prep + an initial charge, times its period's years for each counted period + where the storage reopens per period, or a non-extendable build — is + folded in here by data prep dims: [global_constraint] - snapshot_is_last: - description: one at the horizon's last snapshot, zero elsewhere — data prep, how an expression reads a final level - dims: [snapshot] - dtype: int + GlobalConstraint_counts_snapshot: + description: >- + whether a row counts a snapshot — PyPSA's `investment_period`: every + snapshot where the row names none, and only that period's where it + names one, data prep. A row that names a period the run does not model + has no label here, as PyPSA skips it (`global_constraints.py:377`); + PyPSA reads the column only under `multi_investment_periods`, and fails + on a row that names a period without it (`global_constraints.py:375`) + dims: [global_constraint, snapshot] + dtype: bool Generator_primary_energy_weight: description: >- the constrained attribute per unit of energy at the bus — the carrier's @@ -959,36 +972,68 @@ parameters: description: one where the store is in the row's set — data prep; one outside it has no row dims: [global_constraint, store] Line_volume_weight: - description: the line's length where its carrier is in the row's set — data prep; a line outside it has no row + description: >- + the line's length where its carrier is in the row's set — data prep; a + line outside it, or one that does not stand in the row's + `investment_period`, has no row dims: [global_constraint, line] Link_volume_weight: - description: the link's length where its carrier is in the row's set — data prep; a link outside it has no row + description: >- + the link's length where its carrier is in the row's set — data prep; a + link outside it, or one that does not stand in the row's + `investment_period`, has no row dims: [global_constraint, link] Line_expansion_cost_weight: - description: the line's capital cost where its carrier is in the row's set — data prep; a line outside it has no row + description: >- + the line's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + line outside the set, or one that does not stand in the row's period, + has no row dims: [global_constraint, line] Link_expansion_cost_weight: - description: the link's capital cost where its carrier is in the row's set — data prep; a link outside it has no row + description: >- + the link's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + link outside the set, or one that does not stand in the row's period, + has no row dims: [global_constraint, link] Generator_tech_capacity_weight: description: >- one where the generator is in the row's carrier-and-bus set — data - prep; one outside it has no row + prep; one outside it, or one that does not stand in the row's + `investment_period`, has no row dims: [global_constraint, generator] Link_tech_capacity_weight: - description: one where the link is in the row's carrier-and-bus set — data prep; one outside it has no row + description: >- + one where the link is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row dims: [global_constraint, link] Line_tech_capacity_weight: - description: one where the line is in the row's carrier-and-bus set — data prep; one outside it has no row + description: >- + one where the line is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row dims: [global_constraint, line] StorageUnit_tech_capacity_weight: - description: one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it has no row + description: >- + one where the storage unit is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row dims: [global_constraint, storage_unit] Store_tech_capacity_weight: - description: one where the store is in the row's carrier-and-bus set — data prep; one outside it has no row + description: >- + one where the store is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row dims: [global_constraint, store] Process_tech_capacity_weight: - description: one where the process is in the row's carrier-and-bus set — data prep; one outside it has no row + description: >- + one where the process is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row dims: [global_constraint, process] relations: @@ -1834,24 +1879,75 @@ expressions: when: Process_output_cyclic_delay expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap') otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0) + GlobalConstraint_energy_weight: + description: >- + what one unit of power at a snapshot counts for in a row — the + generator weighting times the years of the snapshot's period, where the + row counts the snapshot, and nothing where it does not + dims: [global_constraint, snapshot] + cases: + counted: + when: GlobalConstraint_counts_snapshot + expression: snapshot_weightings_generators * at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + otherwise: 0 + GlobalConstraint_snapshot_closes: + description: one at the last snapshot a row counts, and zero elsewhere + dims: [global_constraint, snapshot] + cases: + last_counted: + when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) + expression: 1 + otherwise: 0 + StorageUnit_closing_weight: + description: >- + what the charge a unit holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the unit reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [global_constraint, snapshot, storage_unit] + cases: + per_period: + when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT StorageUnit_state_of_charge_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 + Store_closing_weight: + description: >- + what the energy a store holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the store reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [global_constraint, snapshot, store] + cases: + per_period: + when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT Store_e_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 primary_energy: description: >- - what a `primary_energy` row totals — weighted generator energy, less - the charge left in weighted storage at the horizon's end; the initial - charge it is compared against is folded into the row's constant + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant expression: >- - sum(sum(Generator_p * snapshot_weightings_generators * Generator_primary_energy_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * snapshot_is_last * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * snapshot_is_last * Store_primary_energy_weight, over=snapshot), over=store) + sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_primary_energy_weight, over=snapshot), over=generator) + - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + - sum(sum(Store_e * Store_closing_weight * Store_primary_energy_weight, over=snapshot), over=store) operational_limit: description: >- what an `operational_limit` row totals — the weighted energy its - generators deliver, plus what its non-cyclic storage draws down; the - initial charge it draws from is folded into the row's constant + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant expression: >- - sum(sum(Generator_p * snapshot_weightings_generators * Generator_operational_limit_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * snapshot_is_last * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * snapshot_is_last * Store_operational_limit_weight, over=snapshot), over=store) + sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_operational_limit_weight, over=snapshot), over=generator) + - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + - sum(sum(Store_e * Store_closing_weight * Store_operational_limit_weight, over=snapshot), over=store) transmission_volume_expansion: description: what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches expression: >- @@ -3614,6 +3710,48 @@ assumptions: a store that opens past the first snapshot of the horizon opens on its initial level, or its last level where it is cyclic, only where `Store_opens_late` marks the snapshot + StorageUnit_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "StorageUnit_primary_energy_weight AND StorageUnit_state_of_charge_initial_per_period" + description: >- + PyPSA reads the closing charge of a unit that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:474`) + StorageUnit_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "StorageUnit_primary_energy_weight AND NOT StorageUnit_state_of_charge_initial_per_period" + description: >- + a unit that carries its charge from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:448`) + StorageUnit_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "StorageUnit_operational_limit_weight AND NOT StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:647`) + Store_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "Store_primary_energy_weight AND Store_e_initial_per_period" + description: >- + PyPSA reads the closing energy of a store that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:526`) + Store_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "Store_primary_energy_weight AND NOT Store_e_initial_per_period" + description: >- + a store that carries its energy from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:500`) + Store_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "Store_operational_limit_weight AND NOT Store_e_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:695`) objective: sense: minimize diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 6c90cd23..c968e575 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1393,5 +1393,50 @@ "Process-status-p-fixed-upper": 8, "Process-status-p_nom-variable-upper": 4 } + }, + "rung_35_period_global_constraints": { + "columns": { + "Generator-p": 32, + "StorageUnit-p_dispatch": 8, + "StorageUnit-p_store": 8, + "StorageUnit-state_of_charge": 8, + "Store-e": 8, + "Store-p": 8 + }, + "global_constraints": { + "co2_2030": { + "sense": "<=", + "type": "primary_energy" + }, + "co2_all": { + "sense": "<=", + "type": "primary_energy" + }, + "hydro_2020": { + "sense": "<=", + "type": "operational_limit" + } + }, + "objective": 4886.764705882353, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 32, + "Generator-fix-p-upper": 32, + "GlobalConstraint-co2_2030": 1, + "GlobalConstraint-co2_all": 1, + "GlobalConstraint-hydro_2020": 1, + "StorageUnit-energy_balance": 8, + "StorageUnit-fix-p_dispatch-lower": 8, + "StorageUnit-fix-p_dispatch-upper": 8, + "StorageUnit-fix-p_store-lower": 8, + "StorageUnit-fix-p_store-upper": 8, + "StorageUnit-fix-state_of_charge-lower": 8, + "StorageUnit-fix-state_of_charge-upper": 8, + "Store-energy_balance": 8, + "Store-fix-e-lower": 8, + "Store-fix-e-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_35_period_global_constraints.py b/examples/references/pypsa/rung_35_period_global_constraints.py new file mode 100644 index 00000000..9e90c82a --- /dev/null +++ b/examples/references/pypsa/rung_35_period_global_constraints.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 35: a global constraint for one investment period — a CO2 cap on 2030 alone, one over the horizon, and a 2020 limit on a carrier with storage that reopens per period.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: eight snapshots over two periods of unequal years, two emitting units, a hydro carrier with a unit and storage.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [5.0, 10.0] + n.snapshot_weightings['generators'] = [1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0] + n.add('Carrier', 'coal35', co2_emissions=1.0) + n.add('Carrier', 'gas35', co2_emissions=0.4) + n.add('Carrier', 'hydro35') + n.add('Bus', 'hub') + n.add('Generator', 'coal35', bus='hub', carrier='coal35', p_nom=100, marginal_cost=10, efficiency=0.4) + n.add('Generator', 'gas35', bus='hub', carrier='gas35', p_nom=100, marginal_cost=30, efficiency=0.5) + n.add('Generator', 'clean35', bus='hub', p_nom=200, marginal_cost=60) + n.add('Generator', 'river35', bus='hub', carrier='hydro35', p_nom=30, marginal_cost=5) + n.add( + 'StorageUnit', + 'dam35', + bus='hub', + carrier='hydro35', + p_nom=15, + max_hours=4, + state_of_charge_initial=20, + state_of_charge_initial_per_period=True, + ) + n.add('Store', 'pond35', bus='hub', carrier='hydro35', e_nom=30, e_initial=10, e_initial_per_period=True) + n.add('Load', 'town35', bus='hub', p_set=[60, 80, 70, 50, 90, 110, 100, 80]) + n.add( + 'GlobalConstraint', + 'co2_2030', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=3500, + investment_period=2030, + ) + n.add( + 'GlobalConstraint', + 'co2_all', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=6000, + ) + n.add( + 'GlobalConstraint', + 'hydro_2020', + type='operational_limit', + carrier_attribute='hydro35', + sense='<=', + constant=600, + investment_period=2020, + ) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index f8475175..8b1299a9 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -219,7 +219,11 @@ names: GlobalConstraint_type: '\mathrm{type}' GlobalConstraint_sense: '\mathrm{sense}' GlobalConstraint_constant: '\mathrm{K}' - snapshot_is_last: '\mathrm{last}' + GlobalConstraint_counts_snapshot: '\mathrm{in}' + GlobalConstraint_energy_weight: '\mathit{w}^{\mathrm{gc}}' + StorageUnit_closing_weight: '\mathit{w}^{h}' + Store_closing_weight: '\mathit{w}^{e}' + GlobalConstraint_snapshot_closes: '\mathit{last}' Generator_primary_energy_weight: '\mathrm{a}' StorageUnit_primary_energy_weight: '\mathrm{a}^{h}' Store_primary_energy_weight: '\mathrm{a}^{e}' @@ -285,6 +289,7 @@ names: CVaR_theta: '\theta' CVaR: "CVaR" period_weight_objective: '\mathrm{w}^{y}' + period_weight_years: '\mathrm{w}^{\mathrm{yr}}' Generator_active: '\mathrm{on}' Link_active: '\mathrm{on}^{f}' StorageUnit_active: '\mathrm{on}^{h}' diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index b34c500e..fd3c36c8 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -924,8 +924,6 @@ parameters: Transformer_s_set: - snapshot - transformer - snapshot_is_last: - - snapshot snapshot_weightings_generators: - snapshot snapshot_weightings_objective: From 510b56702741589499217e45bee5eace906fb09e Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:03:31 +0200 Subject: [PATCH 23/75] docs(pypsa): a process, a storage unit and a store may carry a quadratic marginal cost --- docs/examples/pypsa.md | 180 ++++++++++++++++-- examples/pypsa.yaml | 58 +++++- examples/references/pypsa/references.json | 35 ++++ .../rung_36_quadratic_storage_process.py | 30 +++ examples/symbols/pypsa.yaml | 3 + 5 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 examples/references/pypsa/rung_36_quadratic_storage_process.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 31831d22..0a53e0fc 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -87,7 +87,7 @@ def build(): | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | | [`marginal_cost`](#objective) | done | | -| [`marginal_cost_quadratic`](#objective) | done | rung 10, below; Generator and Link — PyPSA also carries it on storage units and stores, one more term each of the same shape | +| [`marginal_cost_quadratic`](#objective) | done | rungs 10 and 36, below; Generator and Link `p`, Process `p`, StorageUnit `p_dispatch` only, and Store net `p` | | `objective_constant` | split | an objective shift, compared net of `n._objective_constant` — rungs 11 and 13 carry a nonzero one, `21915277.52` and `160.0`, so the netting is under test | @@ -817,9 +817,9 @@ def build(): A marginal cost quadratic in output: PyPSA's `marginal_cost_quadratic`, one squared term per component in the objective, each snapshot weighted by the hours -it stands for. Generator and Link carry it here; PyPSA also carries it on -storage units and stores, one more term each of the same shape. A plain run -feeds zero, so the term vanishes and the objective stays linear. +it stands for. Generator and Link carry it here; rung 36 puts it on a process, +a storage unit and a store. A plain run feeds zero, so the term vanishes and +the objective stays linear. | PyPSA | status | note | | --- | --- | --- | @@ -1295,15 +1295,17 @@ scenario as well. Capacity does not, because it is chosen once before the future is known. The operating cost is the expectation over the scenarios' weights. A risk preference adds the CVaR (conditional value at risk) rows: an excess per scenario and the tail's average, blended into the objective at -`omega`. +`omega`. PyPSA builds neither row without a risk preference +(`optimize.py:458`). The file builds them only where `omega` is positive, so a +plain run, and a risk preference with `omega = 0`, has none. | PyPSA | status | note | | --- | --- | --- | | [`Generator-p`, `Link-p`](#variable-domains) | done | over `scenario`; `Generator-p_nom` is not — chosen once | | [`Generator-fix-p-*`, `-ext-p-*`, `Link-fix-p-*`, `Bus-nodal_balance`](#generator-fix-p-lower) | done | rungs 1 and 3, over `scenario` | | [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | -| [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | -| [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | +| [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension; none where `omega` is zero | +| [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep; none where `omega` is zero | | [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | @@ -3026,6 +3028,68 @@ def build(): +### Rung 36 — quadratic costs on a process and on storage + +PyPSA's `marginal_cost_quadratic` on the three other components that carry it +(`variables.csv:22`, `:30`, `:33`): a process pays on its internal power `p`, a +storage unit on `p_dispatch` only, and a store on its net `p`, so charging +costs as much as delivering. Each term is the square times the cost, weighted +as the linear term is (`optimize.py:317-334`). A plain run feeds zero, so the +terms vanish. + +The rung puts a quadratic cost on a process, a storage unit, with a cost that +changes per snapshot, and a store. With the same network and no quadratic +cost, PyPSA solves to `17641.666666666668`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`marginal_cost_quadratic`](#objective) on Process, StorageUnit and Store | done | degree 2 in the objective; `p_store` is not charged | +| a quadratic cost under a risk preference | refused, as PyPSA | assumed: [`Generator_marginal_cost_quadratic_without_risk_preference`](#generator_marginal_cost_quadratic_without_risk_preference), and the `Link`, `Process`, `StorageUnit` and `Store` ones | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `19185.241281403858`, 84 rows. + +
+The network, as PyPSA code + +`rung_36_quadratic_storage_process.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 36: quadratic costs on a process, a storage unit and a store — the storage unit pays on dispatch only, the store on its net power both ways.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'hub') + n.add('Process', 'conv36', bus0='north', bus1='hub', p_nom=60, marginal_cost=1, marginal_cost_quadratic=0.05) + n.add( + 'StorageUnit', + 'battery36', + bus='south', + p_nom=20, + max_hours=4, + state_of_charge_initial=40, + marginal_cost=0.5, + marginal_cost_quadratic=[0.2, 0.1, 0.3, 0.1], + ) + n.add('Store', 'tank36', bus='hub', e_nom=60, e_initial=20, marginal_cost_quadratic=0.4) + n.add('Load', 'hub_load', bus='hub', p_set=[20, 45, 30, 50]) + n.add('Load', 'peak36', bus='south', p_set=[10, 40, 20, 60]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3036,6 +3100,7 @@ where it should live — language, data prep, or harness — is one open questio | PyPSA raises | on | here | note | | -------------------------------------------- | ------------------------------------------------- | ----------------------- | ---- | +| `ValueError`, `optimize.py:467-474` | a nonzero `marginal_cost_quadratic` on any Generator, Link, Process, StorageUnit or Store under a risk preference | assumed where `omega > 0`: [`Generator_marginal_cost_quadratic_without_risk_preference`](#generator_marginal_cost_quadratic_without_risk_preference), and the `Link`, `Process`, `StorageUnit` and `Store` ones. The file cannot tell no risk preference from `omega = 0`, which PyPSA also refuses | | | `ValueError`, `constraints.py:1850` | fixed modular `p_nom` not a multiple of `p_nom_mod` | a fractional module cap | X1 | | `ValueError`, `constraints.py:1557` | load on a bus with nothing attached | row not built, unserved | X2 | | `ValueError`, `optimize.py:436` | no component carries a cost | feasibility problem | X3 | @@ -3055,7 +3120,7 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows, which stand only where that share is positive. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. #### Sets @@ -3153,6 +3218,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once | | $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the horizon's end — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at the first snapshots is lost | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | +| $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | | $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | @@ -3246,6 +3312,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every unit stands throughout | | $`\mathrm{idle}`$ | `StorageUnit_inactive_snapshots` over $`\mathcal{S}`$ — how many snapshots a storage unit does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic unit reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | +| $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | | $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | @@ -3262,6 +3329,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every store stands throughout | | $`\mathrm{idle}^{e}`$ | `Store_inactive_snapshots` over $`\mathcal{V}`$ — how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic store reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | +| $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | | $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | | $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\mathcal{K}`$ — nominal apparent power | @@ -7408,11 +7476,12 @@ Carrier_growth_limit: CVaR_excess: description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" dims: [scenario] + where: CVaR_omega > 0 expression: CVaR_a - scenario_opex + CVaR_theta >= 0 ``` ```math -a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in \Xi +a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in \Xi \,:\, \omega > 0 ``` ### `CVaR-def` @@ -7423,11 +7492,12 @@ a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in CVaR_def: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] + where: CVaR_omega > 0 expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR ``` ```math -\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR +\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 ``` ### `Generator_previous_status` @@ -8282,10 +8352,13 @@ scenario_opex: + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_p * Process_p * Process_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_p_dispatch * StorageUnit_p_dispatch * StorageUnit_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) @@ -8299,7 +8372,7 @@ scenario_opex: ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` ### `Carrier_additions` @@ -9138,6 +9211,91 @@ Store_operational_limit_carried_over_has_unit_years: ```math \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{b}^{e}_{i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \mathrm{in}_{i,t} ``` + +### `Generator_marginal_cost_quadratic_without_risk_preference` + +```yaml +Generator_marginal_cost_quadratic_without_risk_preference: + holds: "Generator_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive +``` + +```math +\mathrm{c}^{(2)}_{t,g} = 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \omega > 0 +``` + +### `Link_marginal_cost_quadratic_without_risk_preference` + +```yaml +Link_marginal_cost_quadratic_without_risk_preference: + holds: "Link_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive +``` + +```math +\mathrm{c}^{f,(2)}_{t,l} = 0 \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \omega > 0 +``` + +### `Process_marginal_cost_quadratic_without_risk_preference` + +```yaml +Process_marginal_cost_quadratic_without_risk_preference: + holds: "Process_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive +``` + +```math +\mathrm{c}^{z,(2)}_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \omega > 0 +``` + +### `StorageUnit_marginal_cost_quadratic_without_risk_preference` + +```yaml +StorageUnit_marginal_cost_quadratic_without_risk_preference: + holds: "StorageUnit_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive +``` + +```math +\mathrm{c}^{h,(2)}_{t,s} = 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \omega > 0 +``` + +### `Store_marginal_cost_quadratic_without_risk_preference` + +```yaml +Store_marginal_cost_quadratic_without_risk_preference: + holds: "Store_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive +``` + +```math +\mathrm{c}^{q,(2)}_{t,v} = 0 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \omega > 0 +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 0b26fd25..bd82a878 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -8,8 +8,8 @@ description: >- chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' - weights, with a share priced at the tail through the CVaR rows. A plain run - feeds one scenario, one period, all-active masks and unit weights, and the + weights, with a share priced at the tail through the CVaR rows, which stand + only where that share is positive. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the @@ -364,6 +364,9 @@ parameters: Process_marginal_cost: description: cost of one unit of internal power dims: [snapshot, process] + Process_marginal_cost_quadratic: + description: cost of the square of one unit of internal power + dims: [snapshot, process] Process_ramp_limit_up: description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit dims: [process] @@ -703,6 +706,9 @@ parameters: StorageUnit_marginal_cost: description: cost of one unit of dispatch dims: [snapshot, storage_unit] + StorageUnit_marginal_cost_quadratic: + description: cost of the square of one unit of dispatch; storing is not charged + dims: [snapshot, storage_unit] StorageUnit_marginal_cost_storage: description: cost of one unit of charge held over one snapshot dims: [snapshot, storage_unit] @@ -770,6 +776,9 @@ parameters: Store_marginal_cost: description: cost of one unit of power delivered dims: [snapshot, store] + Store_marginal_cost_quadratic: + description: cost of the square of the net power delivered, so charging costs as much as delivering + dims: [snapshot, store] Store_marginal_cost_storage: description: cost of one unit of energy held over one snapshot dims: [snapshot, store] @@ -1975,10 +1984,13 @@ expressions: + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_p * Process_p * Process_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(StorageUnit_p_dispatch * StorageUnit_p_dispatch * StorageUnit_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) @@ -3540,10 +3552,12 @@ constraints: CVaR_excess: description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" dims: [scenario] + where: CVaR_omega > 0 expression: CVaR_a - scenario_opex + CVaR_theta >= 0 CVaR_def: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] + where: CVaR_omega > 0 expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR assumptions: @@ -3752,6 +3766,46 @@ assumptions: description: >- the same for an `operational_limit` row, over the periods it counts — PyPSA refuses it (`global_constraints.py:695`) + Generator_marginal_cost_quadratic_without_risk_preference: + holds: "Generator_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + Link_marginal_cost_quadratic_without_risk_preference: + holds: "Link_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + Process_marginal_cost_quadratic_without_risk_preference: + holds: "Process_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + StorageUnit_marginal_cost_quadratic_without_risk_preference: + holds: "StorageUnit_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + Store_marginal_cost_quadratic_without_risk_preference: + holds: "Store_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive objective: sense: minimize diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index c968e575..9a7ed1c2 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1438,5 +1438,40 @@ "Store-fix-e-lower": 8, "Store-fix-e-upper": 8 } + }, + "rung_36_quadratic_storage_process": { + "columns": { + "Generator-p": 8, + "Link-p": 4, + "Process-p": 4, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Store-e": 4, + "Store-p": 4 + }, + "global_constraints": {}, + "objective": 19185.241281403858, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Process-fix-p-lower": 4, + "Process-fix-p-upper": 4, + "StorageUnit-energy_balance": 4, + "StorageUnit-fix-p_dispatch-lower": 4, + "StorageUnit-fix-p_dispatch-upper": 4, + "StorageUnit-fix-p_store-lower": 4, + "StorageUnit-fix-p_store-upper": 4, + "StorageUnit-fix-state_of_charge-lower": 4, + "StorageUnit-fix-state_of_charge-upper": 4, + "Store-energy_balance": 4, + "Store-fix-e-lower": 4, + "Store-fix-e-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_36_quadratic_storage_process.py b/examples/references/pypsa/rung_36_quadratic_storage_process.py new file mode 100644 index 00000000..b72f5c7a --- /dev/null +++ b/examples/references/pypsa/rung_36_quadratic_storage_process.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 36: quadratic costs on a process, a storage unit and a store — the storage unit pays on dispatch only, the store on its net power both ways.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Bus', 'hub') + n.add('Process', 'conv36', bus0='north', bus1='hub', p_nom=60, marginal_cost=1, marginal_cost_quadratic=0.05) + n.add( + 'StorageUnit', + 'battery36', + bus='south', + p_nom=20, + max_hours=4, + state_of_charge_initial=40, + marginal_cost=0.5, + marginal_cost_quadratic=[0.2, 0.1, 0.3, 0.1], + ) + n.add('Store', 'tank36', bus='hub', e_nom=60, e_initial=20, marginal_cost_quadratic=0.4) + n.add('Load', 'hub_load', bus='hub', p_set=[20, 45, 30, 50]) + n.add('Load', 'peak36', bus='south', p_set=[10, 40, 20, 60]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 8b1299a9..ea93bb56 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -131,6 +131,7 @@ names: Process_output_delay: '\mathrm{d}^{z}' Process_output_cyclic_delay: '\mathrm{cyc}^{z}' Process_marginal_cost: '\mathrm{c}^{z}' + Process_marginal_cost_quadratic: '\mathrm{c}^{z,(2)}' Process_ramp_limit_up: '\mathrm{ru}^{z}' Process_ramp_limit_down: '\mathrm{rd}^{z}' Process_p_set: '\mathrm{z}^{\mathrm{set}}' @@ -261,6 +262,7 @@ names: StorageUnit_opens_late: '\mathrm{open}' StorageUnit_inactive_snapshots: '\mathrm{idle}' StorageUnit_marginal_cost: '\mathrm{c}^{h}' + StorageUnit_marginal_cost_quadratic: '\mathrm{c}^{h,(2)}' StorageUnit_marginal_cost_storage: '\mathrm{c}^{\mathrm{soc}}' StorageUnit_spill_cost: '\mathrm{c}^{\mathrm{spill}}' StorageUnit_p_set: '\mathrm{h}^{\mathrm{set}}' @@ -280,6 +282,7 @@ names: Store_opens_late: '\mathrm{open}^{e}' Store_inactive_snapshots: '\mathrm{idle}^{e}' Store_marginal_cost: '\mathrm{c}^{q}' + Store_marginal_cost_quadratic: '\mathrm{c}^{q,(2)}' Store_marginal_cost_storage: '\mathrm{c}^{e}' Store_e_set: '\mathrm{e}^{\mathrm{set}}' scenario_weight: '\pi' From a0777a4a3357a103398e7544f17791b14b3f4b60 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:08:55 +0200 Subject: [PATCH 24/75] docs(pypsa): a store's power and a storage unit's dispatch and charging may each be pinned to a schedule --- docs/examples/pypsa.md | 113 +++++++++++++++++- examples/pypsa.yaml | 24 ++++ examples/references/pypsa/references.json | 35 ++++++ .../pypsa/rung_37_fixed_storage_dispatch.py | 29 +++++ examples/symbols/pypsa.yaml | 3 + 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 examples/references/pypsa/rung_37_fixed_storage_dispatch.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 0a53e0fc..481c26c9 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -128,7 +128,7 @@ def build(): | [`StorageUnit-fix-*`](#storageunit-fix-p_dispatch-lower), [`Store-fix-e-*`](#store-fix-e-lower) | done | | | [`StorageUnit-energy_balance`](#storageunit-energy_balance) | done | the charge carried into a snapshot is a cased quantity — cyclic, opening, carried; `(1-loss)**eh` is prep | | [`Store-energy_balance`](#store-energy_balance) | done | same | -| [`StorageUnit-p_set`](#storageunit-p_set), [`{c}-{attr}_set`](#generator-p_set) | done | `Generator-p_set`, `Link-p_set`, `StorageUnit-state_of_charge_set`, `Store-e_set`, `Line-s_set` | +| [`StorageUnit-p_set`](#storageunit-p_set), [`{c}-{attr}_set`](#generator-p_set) | done | `Generator-p_set`, `Link-p_set`, `StorageUnit-state_of_charge_set`, `Store-e_set`, `Line-s_set`; `Store-p_set`, `StorageUnit-p_dispatch_set`, `-p_store_set` in rung 37 | | [`marginal_cost_storage`, `spill_cost`](#objective) | done | | @@ -3090,6 +3090,66 @@ def build(): +### Rung 37 — storage dispatch pinned to a schedule + +PyPSA pins a store's power delivered to `p_set`, and a storage unit's dispatch +and charging to `p_dispatch_set` and `p_store_set`, each on its own +(`optimize.py:846`, `:851`; `constraints.py:1961-2019`). A row stands only +where a value is given and the storage is active. A plain run gives no value, +so no row stands. + +The rung pins a store to deliver 10 in the first snapshot and to take 5 in the +last, and pins a storage unit's dispatch in the first snapshot and its +charging in the second. With the same network and no pins, PyPSA solves to +`5811.111111111111`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Store-p_set`](#store-p_set), [`StorageUnit-p_dispatch_set`](#storageunit-p_dispatch_set), [`StorageUnit-p_store_set`](#storageunit-p_store_set) | done | `where:` a value is given and the storage is active | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `6395.833333333333`, 76 rows. + +
+The network, as PyPSA code + +`rung_37_fixed_storage_dispatch.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 37: storage dispatch pinned — a store's power delivered, and a storage unit's dispatch and charging, each on its own schedule.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.generators_t.marginal_cost['gas'] = [15, 60, 15, 60] + n.add('Store', 'tank37', bus='south', e_nom=40, e_initial=20, p_set=[10, nan, nan, -5]) + n.add( + 'StorageUnit', + 'battery37', + bus='south', + p_nom=20, + max_hours=2, + state_of_charge_initial=10, + p_dispatch_set=[6, nan, nan, nan], + p_store_set=[nan, 4, nan, nan], + ) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3316,6 +3376,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | | $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | | $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | | $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\mathcal{V}`$ — nominal energy capacity | | $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | @@ -3332,6 +3394,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | | $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | +| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | | $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\mathcal{K}`$ — nominal apparent power | | $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | | $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | @@ -7134,6 +7197,38 @@ StorageUnit_p_set: h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} = \mathrm{h}^{\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` +### `StorageUnit-p_dispatch_set` + +`StorageUnit_p_dispatch_set` + +```yaml +StorageUnit_p_dispatch_set: + description: "`StorageUnit-p_dispatch_set` — dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_dispatch_set AND StorageUnit_active + expression: StorageUnit_p_dispatch == StorageUnit_p_dispatch_set +``` + +```math +h^{+}_{\xi,t,s} = \mathrm{h}^{+,\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{+,\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + +### `StorageUnit-p_store_set` + +`StorageUnit_p_store_set` + +```yaml +StorageUnit_p_store_set: + description: "`StorageUnit-p_store_set` — charging pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_store_set AND StorageUnit_active + expression: StorageUnit_p_store == StorageUnit_p_store_set +``` + +```math +h^{-}_{\xi,t,s} = \mathrm{h}^{-,\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{-,\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + ### `StorageUnit-state_of_charge_set` `StorageUnit_state_of_charge_set` @@ -7166,6 +7261,22 @@ Store_e_set: e_{\xi,t,v} = \mathrm{e}^{\mathrm{set}}_{t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} ``` +### `Store-p_set` + +`Store_p_set` + +```yaml +Store_p_set: + description: "`Store-p_set` — power delivered pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_p_set AND Store_active + expression: Store_p == Store_p_set +``` + +```math +q_{\xi,t,v} = \mathrm{q}^{\mathrm{set}}_{t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{q}^{\mathrm{set}}_{t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} +``` + ### `primary_energy` `GlobalConstraint_primary_energy_ub` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index bd82a878..0f7915f4 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -718,6 +718,12 @@ parameters: StorageUnit_p_set: description: a given net dispatch schedule; a unit without one has no row here dims: [snapshot, storage_unit] + StorageUnit_p_dispatch_set: + description: a given dispatch schedule; a unit without one has no row here + dims: [snapshot, storage_unit] + StorageUnit_p_store_set: + description: a given charging schedule; a unit without one has no row here + dims: [snapshot, storage_unit] StorageUnit_state_of_charge_set: description: a given charge schedule; a unit without one has no row here dims: [snapshot, storage_unit] @@ -785,6 +791,9 @@ parameters: Store_e_set: description: a given energy schedule; a store without one has no row here dims: [snapshot, store] + Store_p_set: + description: a given schedule of power delivered; a store without one has no row here + dims: [snapshot, store] Line_s_nom: description: nominal apparent power dims: [line] @@ -3424,6 +3433,16 @@ constraints: dims: [scenario, snapshot, storage_unit] where: StorageUnit_p_set AND StorageUnit_active expression: StorageUnit_p_dispatch - StorageUnit_p_store == StorageUnit_p_set + StorageUnit_p_dispatch_set: + description: "`StorageUnit-p_dispatch_set` — dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_dispatch_set AND StorageUnit_active + expression: StorageUnit_p_dispatch == StorageUnit_p_dispatch_set + StorageUnit_p_store_set: + description: "`StorageUnit-p_store_set` — charging pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_store_set AND StorageUnit_active + expression: StorageUnit_p_store == StorageUnit_p_store_set StorageUnit_state_of_charge_set: description: "`StorageUnit-state_of_charge_set` — charge pinned to the given schedule, wherever one is given" dims: [scenario, snapshot, storage_unit] @@ -3434,6 +3453,11 @@ constraints: dims: [scenario, snapshot, store] where: Store_e_set AND Store_active expression: Store_e == Store_e_set + Store_p_set: + description: "`Store-p_set` — power delivered pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_p_set AND Store_active + expression: Store_p == Store_p_set GlobalConstraint_primary_energy_ub: description: "`primary_energy` — its total, at most its constant" dims: [scenario, global_constraint] diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 9a7ed1c2..145e074b 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1473,5 +1473,40 @@ "Store-fix-e-lower": 4, "Store-fix-e-upper": 4 } + }, + "rung_37_fixed_storage_dispatch": { + "columns": { + "Generator-p": 8, + "Link-p": 4, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Store-e": 4, + "Store-p": 4 + }, + "global_constraints": {}, + "objective": 6395.833333333333, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "StorageUnit-energy_balance": 4, + "StorageUnit-fix-p_dispatch-lower": 4, + "StorageUnit-fix-p_dispatch-upper": 4, + "StorageUnit-fix-p_store-lower": 4, + "StorageUnit-fix-p_store-upper": 4, + "StorageUnit-fix-state_of_charge-lower": 4, + "StorageUnit-fix-state_of_charge-upper": 4, + "StorageUnit-p_dispatch_set": 1, + "StorageUnit-p_store_set": 1, + "Store-energy_balance": 4, + "Store-fix-e-lower": 4, + "Store-fix-e-upper": 4, + "Store-p_set": 2 + } } } diff --git a/examples/references/pypsa/rung_37_fixed_storage_dispatch.py b/examples/references/pypsa/rung_37_fixed_storage_dispatch.py new file mode 100644 index 00000000..f4fa6bf4 --- /dev/null +++ b/examples/references/pypsa/rung_37_fixed_storage_dispatch.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 37: storage dispatch pinned — a store's power delivered, and a storage unit's dispatch and charging, each on its own schedule.""" + +from __future__ import annotations + +from math import nan + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.generators_t.marginal_cost['gas'] = [15, 60, 15, 60] + n.add('Store', 'tank37', bus='south', e_nom=40, e_initial=20, p_set=[10, nan, nan, -5]) + n.add( + 'StorageUnit', + 'battery37', + bus='south', + p_nom=20, + max_hours=2, + state_of_charge_initial=10, + p_dispatch_set=[6, nan, nan, nan], + p_store_set=[nan, 4, nan, nan], + ) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index ea93bb56..6852461b 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -266,6 +266,8 @@ names: StorageUnit_marginal_cost_storage: '\mathrm{c}^{\mathrm{soc}}' StorageUnit_spill_cost: '\mathrm{c}^{\mathrm{spill}}' StorageUnit_p_set: '\mathrm{h}^{\mathrm{set}}' + StorageUnit_p_dispatch_set: '\mathrm{h}^{+,\mathrm{set}}' + StorageUnit_p_store_set: '\mathrm{h}^{-,\mathrm{set}}' StorageUnit_state_of_charge_set: '\mathrm{soc}^{\mathrm{set}}' Store_e: "e" Store_p: "q" @@ -285,6 +287,7 @@ names: Store_marginal_cost_quadratic: '\mathrm{c}^{q,(2)}' Store_marginal_cost_storage: '\mathrm{c}^{e}' Store_e_set: '\mathrm{e}^{\mathrm{set}}' + Store_p_set: '\mathrm{q}^{\mathrm{set}}' scenario_weight: '\pi' CVaR_omega: '\omega' CVaR_inv_tail: '\mathrm{v}' From d96464991fae6617ea803ba1c33a7fc43fe5454b Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:16:43 +0200 Subject: [PATCH 25/75] docs(pypsa): a link's and a process's delay applies within each investment period --- docs/examples/pypsa.md | 130 +++++++++++++++--- examples/pypsa.yaml | 39 +++--- examples/references/pypsa/references.json | 20 +++ .../pypsa/rung_38_delay_per_period.py | 53 +++++++ 4 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 examples/references/pypsa/rung_38_delay_per_period.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 481c26c9..251e3930 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1363,6 +1363,7 @@ own dimensions. | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) for storage built in a later period or retired early | done | rung 32 | | [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit) for one investment period, weighted by period years | done | rung 35 | +| [link and process `delay`, `cyclic_delay`](#bus-nodal_balance) per investment period | done | rung 38 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `12747.19109626398`, 80 rows. @@ -3150,6 +3151,96 @@ def build(): +### Rung 38 — delays per investment period + +`n.optimize(multi_investment_periods=True)` with delayed ports. PyPSA applies +a link's or a process's `delay` in each investment period on its own +(`constraints.py:1324-1332`; `multiports.py:212-219`). A `cyclic_delay` port +wraps from the end of its own period. A port that is not cyclic loses the flow +still in transit at the first snapshots of every period. PyPSA measures the +delay in `generators` weighting per period and rounds it down to a snapshot +start (`multiports.py:106-123`). Scenarios do not change the source snapshot. +`Link_output_arrival` and `Process_output_arrival` therefore shift with +`by=snapshot_period, within=period`. A plain run has one period, so the shift +is the flat one. + +The rung builds a link that delays by two and wraps, and a process that +delays by one and does not wrap, on two periods of four snapshots. The earlier +file shifted over the flat horizon, so 2030 read the flow sent in 2020. With +that flat shift patched into PyPSA's source index, the network solves to +`10543.75` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [link and process `delay`, `cyclic_delay`](#bus-nodal_balance) per investment period | done | `shift(offset=delay, by=snapshot_period, within=period)`; `edge='wrap'` closes each period, `edge=0` vacates each period's first snapshots | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `12918.75`, 104 rows. + +
+The network, as PyPSA code + +`rung_38_delay_per_period.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 38: delays per investment period — a link that wraps its delayed flow within each period, and a process that loses what is still in transit at each period's start.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + +#: The `generators` weighting is uniform, as on rung 16, so a delay of `n` is a +#: shift of exactly `n` positions. The `objective` column stays non-uniform. +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0, 2.0, 1.5, 2.5, 3.0], 'generators': [1.0] * 8} + +#: Demand differs from snapshot to snapshot, so which snapshot a delayed flow is +#: read from changes what it costs. +DEMAND = [20.0, 15.0, 25.0, 10.0, 30.0, 35.0, 5.0, 40.0] + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, a source, a delayed link and a delayed process. + + ``pipe_wrap`` delays by two snapshots and wraps cyclically, so the first two + snapshots of each period read the last two of that same period, never the + other period. ``conv_lose`` delays by one and does not wrap, so the first + snapshot of each period, 2030 included, receives nothing and its demand falls + to the backup. The source is capped, so where each delayed flow is read from + decides how much of the backup runs. + """ + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', ['source', 'sink_wrap', 'sink_lose']) + n.add('Generator', 'spring38', bus='source', p_nom=60, marginal_cost=5) + n.add('Generator', 'backup_wrap38', bus='sink_wrap', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_lose38', bus='sink_lose', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe_wrap', bus0='source', bus1='sink_wrap', p_nom=30, delay=2, cyclic_delay=True) + n.add('Process', 'conv_lose', bus0='source', bus1='sink_lose', p_nom=30, delay1=1, cyclic_delay1=False) + n.add('Load', 'load_wrap', bus='sink_wrap', p_set=DEMAND) + n.add('Load', 'load_lose', bus='sink_lose', p_set=DEMAND) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3247,7 +3338,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | | $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | | $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once | -| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the horizon's end — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at the first snapshots is lost | +| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | @@ -3276,7 +3367,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | | $`\alpha`$ | `Process_rate` over $`\mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | | $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once | -| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the horizon's end — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at the first snapshots is lost | +| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | | $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | | $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | @@ -3537,8 +3628,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial charge, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A unit built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | -| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed flow wraps from the horizon's end, and where it is not the flow still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | -| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay`; where the port is `cyclic_delay` the delayed transfer wraps from the horizon's end, and where it is not the energy still in transit at the first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | +| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed flow wraps from the period's end, and where it is not the flow still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | +| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\mathcal{I} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | | $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | | $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | @@ -8244,21 +8335,21 @@ Store_energy_carried_in: Link_output_arrival: description: >- what a link delivers to an output port at a snapshot — its flow after the - port's efficiency, delayed by the port's `delay`; where the port is - `cyclic_delay` the delayed flow wraps from the horizon's end, and where it - is not the flow still in transit at the first snapshots is lost. A port - that does not delay (`delay` zero) delivers its flow unshifted, cyclic or - not + port's efficiency, delayed by the port's `delay` within its investment + period; where the port is `cyclic_delay` the delayed flow wraps from the + period's end, and where it is not the flow still in transit at the + period's first snapshots is lost. A port that does not delay (`delay` + zero) delivers its flow unshifted, cyclic or not dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay - expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap') - otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0) + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) ``` ```math -\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` ### `Process_output_arrival` @@ -8267,20 +8358,21 @@ Link_output_arrival: Process_output_arrival: description: >- what a process transfers at a port at a snapshot — its internal power - times the port's rate, delayed by the port's `delay`; where the port is - `cyclic_delay` the delayed transfer wraps from the horizon's end, and where - it is not the energy still in transit at the first snapshots is lost. A - port that does not delay (`delay` zero) transfers at once, cyclic or not + times the port's rate, delayed by the port's `delay` within its + investment period; where the port is `cyclic_delay` the delayed transfer + wraps from the period's end, and where it is not the energy still in + transit at the period's first snapshots is lost. A port that does not + delay (`delay` zero) transfers at once, cyclic or not dims: [scenario, snapshot, process_output] cases: wrapping: when: Process_output_cyclic_delay - expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap') - otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0) + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) ``` ```math -\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} ``` ### `GlobalConstraint_energy_weight` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 0f7915f4..08010675 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -224,9 +224,9 @@ parameters: dtype: int Link_output_cyclic_delay: description: >- - whether a delayed port's flow wraps from the horizon's end — PyPSA's - `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in - transit at the first snapshots is lost + whether a delayed port's flow wraps from the end of its investment + period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, + the flow still in transit at each period's first snapshots is lost dims: [link_output] dtype: bool Link_marginal_cost: @@ -356,9 +356,9 @@ parameters: dtype: int Process_output_cyclic_delay: description: >- - whether a delayed port's transfer wraps from the horizon's end — PyPSA's - `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still - in transit at the first snapshots is lost + whether a delayed port's transfer wraps from the end of its investment + period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, + the energy still in transit at each period's first snapshots is lost dims: [process_output] dtype: bool Process_marginal_cost: @@ -1873,30 +1873,31 @@ expressions: Link_output_arrival: description: >- what a link delivers to an output port at a snapshot — its flow after the - port's efficiency, delayed by the port's `delay`; where the port is - `cyclic_delay` the delayed flow wraps from the horizon's end, and where it - is not the flow still in transit at the first snapshots is lost. A port - that does not delay (`delay` zero) delivers its flow unshifted, cyclic or - not + port's efficiency, delayed by the port's `delay` within its investment + period; where the port is `cyclic_delay` the delayed flow wraps from the + period's end, and where it is not the flow still in transit at the + period's first snapshots is lost. A port that does not delay (`delay` + zero) delivers its flow unshifted, cyclic or not dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay - expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap') - otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0) + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) Process_output_arrival: description: >- what a process transfers at a port at a snapshot — its internal power - times the port's rate, delayed by the port's `delay`; where the port is - `cyclic_delay` the delayed transfer wraps from the horizon's end, and where - it is not the energy still in transit at the first snapshots is lost. A - port that does not delay (`delay` zero) transfers at once, cyclic or not + times the port's rate, delayed by the port's `delay` within its + investment period; where the port is `cyclic_delay` the delayed transfer + wraps from the period's end, and where it is not the energy still in + transit at the period's first snapshots is lost. A port that does not + delay (`delay` zero) transfers at once, cyclic or not dims: [scenario, snapshot, process_output] cases: wrapping: when: Process_output_cyclic_delay - expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap') - otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0) + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) GlobalConstraint_energy_weight: description: >- what one unit of power at a snapshot counts for in a row — the diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 145e074b..9e2f49c4 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1508,5 +1508,25 @@ "Store-fix-e-upper": 4, "Store-p_set": 2 } + }, + "rung_38_delay_per_period": { + "columns": { + "Generator-p": 24, + "Link-p": 8, + "Process-p": 8 + }, + "global_constraints": {}, + "objective": 12918.75, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 24, + "Generator-fix-p-lower": 24, + "Generator-fix-p-upper": 24, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8, + "Process-fix-p-lower": 8, + "Process-fix-p-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_38_delay_per_period.py b/examples/references/pypsa/rung_38_delay_per_period.py new file mode 100644 index 00000000..38303eda --- /dev/null +++ b/examples/references/pypsa/rung_38_delay_per_period.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 38: delays per investment period — a link that wraps its delayed flow within each period, and a process that loses what is still in transit at each period's start.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + +#: The `generators` weighting is uniform, as on rung 16, so a delay of `n` is a +#: shift of exactly `n` positions. The `objective` column stays non-uniform. +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0, 2.0, 1.5, 2.5, 3.0], 'generators': [1.0] * 8} + +#: Demand differs from snapshot to snapshot, so which snapshot a delayed flow is +#: read from changes what it costs. +DEMAND = [20.0, 15.0, 25.0, 10.0, 30.0, 35.0, 5.0, 40.0] + + +def build(): + """A whole network, not the spine: eight snapshots over two periods, a source, a delayed link and a delayed process. + + ``pipe_wrap`` delays by two snapshots and wraps cyclically, so the first two + snapshots of each period read the last two of that same period, never the + other period. ``conv_lose`` delays by one and does not wrap, so the first + snapshot of each period, 2030 included, receives nothing and its demand falls + to the backup. The source is capped, so where each delayed flow is read from + decides how much of the backup runs. + """ + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(4)] + [(2030, datetime(2030, 1, 1, t)) for t in range(4)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', ['source', 'sink_wrap', 'sink_lose']) + n.add('Generator', 'spring38', bus='source', p_nom=60, marginal_cost=5) + n.add('Generator', 'backup_wrap38', bus='sink_wrap', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_lose38', bus='sink_lose', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe_wrap', bus0='source', bus1='sink_wrap', p_nom=30, delay=2, cyclic_delay=True) + n.add('Process', 'conv_lose', bus0='source', bus1='sink_lose', p_nom=30, delay1=1, cyclic_delay1=False) + n.add('Load', 'load_wrap', bus='sink_wrap', p_set=DEMAND) + n.add('Load', 'load_lose', bus='sink_lose', p_set=DEMAND) + return n From c1fec47db1e119d35a4eda264bcab1aaa2b48b4b Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:46:48 +0200 Subject: [PATCH 26/75] docs(pypsa): a negative relative growth adds nothing to a carrier's growth limit, as pypsa clips it at zero --- docs/examples/pypsa.md | 114 +++++++++++++++++- examples/pypsa.yaml | 11 +- examples/references/pypsa/references.json | 23 ++++ .../pypsa/rung_39_negative_relative_growth.py | 57 +++++++++ examples/symbols/pypsa.yaml | 1 + 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 examples/references/pypsa/rung_39_negative_relative_growth.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 251e3930..4c07528f 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1359,6 +1359,7 @@ own dimensions. | [`Generator-p`](#variable-domains) | done | where the generator stands in the snapshot's period — `active`, data prep | | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | +| [`Carrier-growth_limit`](#carrier-growth_limit) with a negative `max_relative_growth` | done | rung 39 | | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) for storage built in a later period or retired early | done | rung 32 | @@ -3241,6 +3242,96 @@ def build(): +### Rung 39 — a negative relative growth + +`n.optimize(multi_investment_periods=True)` with a carrier whose +`max_relative_growth` is negative. PyPSA clips the share at zero before it +builds `Carrier-growth_limit` (`global_constraints.py:237`), so a negative +share adds nothing and does not tighten the limit. `Carrier_relative_growth` +states the clip as a case: the given share where it is positive, zero +otherwise. A plain run has one period and builds no growth row. + +The rung builds a battery carrier with `max_growth=20` and +`max_relative_growth=-0.5`, and two stores built in 2020 and 2030. The earlier +file read the share as given, so the 2030 row added half of the 2020 build to +the left side. With that row patched into PyPSA through `extra_functionality`, +the network solves to `9347.5` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`Carrier-growth_limit`](#carrier-growth_limit), `max_relative_growth.clip(min=0)` | done | `Carrier_relative_growth` is `Carrier_max_relative_growth` where it is positive, `0` otherwise | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `8600.0`, 44 rows. + +
+The network, as PyPSA code + +`rung_39_negative_relative_growth.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 39: a negative relative growth adds nothing to a carrier's growth limit — PyPSA clips it at zero.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: a battery carrier with `max_relative_growth=-0.5` builds in both periods.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'solar') + n.add('Carrier', 'gas') + n.add('Carrier', 'battery', max_growth=20, max_relative_growth=-0.5) + n.add('Generator', 'solar', bus='grid', carrier='solar', p_nom=100, marginal_cost=1, p_max_pu=[1, 0, 1, 0]) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=200, marginal_cost=80) + n.add( + 'Store', + 'tank20', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=10, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank30', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=8, + build_year=2030, + lifetime=30, + ) + n.add('Load', 'town', bus='grid', p_set=[40, 60, 40, 80]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3641,6 +3732,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | +| $`\mathrm{r}^{+}`$ | `Carrier_relative_growth` over $`\mathcal{I}`$ — the share of the previous period's additions a carrier's growth limit reads — PyPSA's `max_relative_growth` clipped at zero, so a negative share adds nothing and never tightens the limit | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | @@ -7662,12 +7754,12 @@ Carrier_growth_limit: where: Carrier_max_growth expression: >- Carrier_additions - - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_max_relative_growth + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_relative_growth <= Carrier_max_growth ``` ```math -\mathit{Carrier\_additions}_{y,i} - \mathit{Carrier\_additions}_{y \boxminus_{0} 1,i} \cdot \mathrm{r}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} +\mathit{Carrier\_additions}_{y,i} - \mathit{Carrier\_additions}_{y \boxminus_{0} 1,i} \cdot \mathrm{r}^{+}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} ``` ### `CVaR-excess-{s}` @@ -8600,6 +8692,24 @@ Carrier_additions: \mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} ``` +### `Carrier_relative_growth` + +```yaml +Carrier_relative_growth: + description: >- + the share of the previous period's additions a carrier's growth limit + reads — PyPSA's `max_relative_growth` clipped at zero, so a negative + share adds nothing and never tightens the limit + dims: [carrier] + cases: + positive: { when: Carrier_max_relative_growth > 0, expression: Carrier_max_relative_growth } + otherwise: 0 +``` + +```math +\mathrm{r}^{+}_{i} = \begin{cases} \mathrm{r}_{i} & \text{if } \mathrm{r}_{i} > 0 \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I} +``` + ### `Line_s_monitored` ```yaml diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 08010675..716bdb57 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -2024,6 +2024,15 @@ expressions: + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + Carrier_relative_growth: + description: >- + the share of the previous period's additions a carrier's growth limit + reads — PyPSA's `max_relative_growth` clipped at zero, so a negative + share adds nothing and never tightens the limit + dims: [carrier] + cases: + positive: { when: Carrier_max_relative_growth > 0, expression: Carrier_max_relative_growth } + otherwise: 0 Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, @@ -3572,7 +3581,7 @@ constraints: where: Carrier_max_growth expression: >- Carrier_additions - - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_max_relative_growth + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_relative_growth <= Carrier_max_growth CVaR_excess: description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 9e2f49c4..2cf096b6 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1528,5 +1528,28 @@ "Process-fix-p-lower": 8, "Process-fix-p-upper": 8 } + }, + "rung_39_negative_relative_growth": { + "columns": { + "Generator-p": 8, + "Store-e": 6, + "Store-e_nom": 2, + "Store-p": 6 + }, + "global_constraints": {}, + "objective": 8600.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 4, + "Carrier-growth_limit": 2, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Store-energy_balance": 6, + "Store-ext-e-lower": 6, + "Store-ext-e-upper": 6, + "Store-ext-e_nom-lower": 2, + "Store-ext-e_nom-upper": 2 + } } } diff --git a/examples/references/pypsa/rung_39_negative_relative_growth.py b/examples/references/pypsa/rung_39_negative_relative_growth.py new file mode 100644 index 00000000..00ade931 --- /dev/null +++ b/examples/references/pypsa/rung_39_negative_relative_growth.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 39: a negative relative growth adds nothing to a carrier's growth limit — PyPSA clips it at zero.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: a battery carrier with `max_relative_growth=-0.5` builds in both periods.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'solar') + n.add('Carrier', 'gas') + n.add('Carrier', 'battery', max_growth=20, max_relative_growth=-0.5) + n.add('Generator', 'solar', bus='grid', carrier='solar', p_nom=100, marginal_cost=1, p_max_pu=[1, 0, 1, 0]) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=200, marginal_cost=80) + n.add( + 'Store', + 'tank20', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=10, + build_year=2020, + lifetime=30, + ) + n.add( + 'Store', + 'tank30', + bus='grid', + carrier='battery', + e_nom_extendable=True, + e_nom_max=100, + capital_cost=8, + build_year=2030, + lifetime=30, + ) + n.add('Load', 'town', bus='grid', p_set=[40, 60, 40, 80]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 6852461b..f6789c47 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -318,6 +318,7 @@ names: Process_first_active: '\mathrm{new}^{z}' Carrier_max_growth: '\overline{\Delta}' Carrier_max_relative_growth: '\mathrm{r}' + Carrier_relative_growth: '\mathrm{r}^{+}' Generator_maintainable: '\mathrm{mnt}' Link_maintainable: '\mathrm{mnt}^{f}' Process_maintainable: '\mathrm{mnt}^{z}' From 49a8fbd7d83456fe0dbfed724b15b94adfc86e87 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:47:59 +0200 Subject: [PATCH 27/75] docs(pypsa): a tech capacity expansion limit on a network with several scenarios is refused, as pypsa does --- docs/examples/pypsa.md | 19 +++++++++++++++++++ examples/pypsa.yaml | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 4c07528f..ab57959e 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -3352,6 +3352,7 @@ where it should live — language, data prep, or harness — is one open questio | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | | `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | +| `NotImplementedError`, `global_constraints.py:66-68` | a `tech_capacity_expansion_limit` row on a network with scenarios | assumed where there is more than one scenario: [`GlobalConstraint_tech_capacity_expansion_limit_without_scenarios`](#globalconstraint_tech_capacity_expansion_limit_without_scenarios). The file cannot tell one scenario from none, which PyPSA also refuses | | | `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | assumed: [`Generator_maintenance_events_positive`](#generator_maintenance_events_positive), [`-duration_positive`](#generator_maintenance_duration_positive), [`-duration_fits_the_horizon`](#generator_maintenance_duration_fits_the_horizon), [`-events_fit_the_horizon`](#generator_maintenance_events_fit_the_horizon), [`-build_cap_is_finite`](#generator_maintenance_build_cap_is_finite), and the `Link` and `Process` ones | | | nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | assumed: [`Generator_maintenance_module_count_is_finite`](#generator_maintenance_module_count_is_finite), and the `Link` and `Process` ones | | @@ -9609,6 +9610,24 @@ Store_marginal_cost_quadratic_without_risk_preference: ```math \mathrm{c}^{q,(2)}_{t,v} = 0 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \omega > 0 ``` + +### `GlobalConstraint_tech_capacity_expansion_limit_without_scenarios` + +```yaml +GlobalConstraint_tech_capacity_expansion_limit_without_scenarios: + holds: "GlobalConstraint_type != 'tech_capacity_expansion_limit'" + where: "count(scenario_weight, over=scenario) > 1" + description: >- + PyPSA does not build a `tech_capacity_expansion_limit` row on a + network with scenarios and refuses it + (`global_constraints.py:66-68`). The spec cannot tell a network with + one scenario from one with none, so it refuses only where there is + more than one scenario +``` + +```math +\mathrm{type}_{i} \neq \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \qquad \forall\, i \in \mathcal{I} \,:\, \lvert \{ \xi \in \Xi \,:\, \pi_{\xi} \text{ is defined} \} \rvert > 1 +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 716bdb57..3bdaeede 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -3840,6 +3840,15 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive + GlobalConstraint_tech_capacity_expansion_limit_without_scenarios: + holds: "GlobalConstraint_type != 'tech_capacity_expansion_limit'" + where: "count(scenario_weight, over=scenario) > 1" + description: >- + PyPSA does not build a `tech_capacity_expansion_limit` row on a + network with scenarios and refuses it + (`global_constraints.py:66-68`). The spec cannot tell a network with + one scenario from one with none, so it refuses only where there is + more than one scenario objective: sense: minimize From 0f578c7c141e24b8da175e019ea2e48e5508de9f Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 14:48:53 +0200 Subject: [PATCH 28/75] docs(pypsa): the carrier growth rung says a transformer counts in no carrier --- docs/examples/pypsa.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index ab57959e..9198b7a8 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1777,8 +1777,8 @@ extendable component of that carrier, not the generators alone. Here a battery carrier caps a storage unit and a store built in the first period, and the two builds fill the cap together. A store built in the later period adds its allowance plus half of what the carrier added before. PyPSA counts the -components that carry a carrier attribute; the spec extends the same limit to -transformers, which PyPSA leaves out. +components that carry a carrier attribute, and so does the spec, so a +transformer counts in no carrier. > ✔ `pypsa 1.3.0` solves this rung's network at objective `8452.5`, 74 rows. From 6bfa0529f95dfdad3bcff4cbd2117a662929ec53 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 16:05:44 +0200 Subject: [PATCH 29/75] docs(pypsa): a global constraint takes its own constant and sense in each scenario --- docs/examples/pypsa.md | 152 ++++++++++++++---- examples/pypsa.yaml | 30 ++-- examples/references/pypsa/references.json | 41 +++++ .../rung_40_scenario_global_constraints.py | 53 ++++++ tests/test_pypsa_collapses.py | 10 +- tests/test_pypsa_references.py | 6 +- 6 files changed, 244 insertions(+), 48 deletions(-) create mode 100644 examples/references/pypsa/rung_40_scenario_global_constraints.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 9198b7a8..cc57953b 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -414,9 +414,9 @@ each type is three blocks by sense. | PyPSA type | status | note | | ------------------------------------- | ----------- | ------------------------------------------------- | -| [`primary_energy`](#primary_energy) | split | a block per sense — sense as data is beyond #70; carrier weights are prep; one period in rung 35 | -| [`operational_limit`](#operational_limit) | split | a block per sense; one period in rung 35 | -| [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) | split | a block per sense; membership from PyPSA's carrier string is prep | +| [`primary_energy`](#primary_energy) | split | a block per sense — sense as data is beyond #70; carrier weights are prep; one period in rung 35; per scenario in rung 40 | +| [`operational_limit`](#operational_limit) | split | a block per sense; one period in rung 35; per scenario in rung 40 | +| [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) | split | a block per sense; membership from PyPSA's carrier string is prep; per scenario in rung 40 | | [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) | split | a block per sense | | [`tech_capacity_expansion_limit`](#tech_capacity_expansion_limit) | split | a block per sense | | `Bus-nom_min/max_{carrier}` | out | deprecated in PyPSA | @@ -3332,6 +3332,100 @@ def build(): +### Rung 40 — a global constraint per scenario + +`n.set_scenarios(...)` with `GlobalConstraint` rows whose `constant` and +`sense` differ between the scenarios. PyPSA builds one `GlobalConstraint-{name}` +row per scenario for `primary_energy`, `operational_limit` and +`transmission_volume_expansion_limit`, and reads each scenario's own sense and +constant (`global_constraints.py:361-371`, `:556-557`, `:748-749`, +`:786-795`, `:860-861`). The file states `GlobalConstraint_constant` and +`GlobalConstraint_sense` over `scenario` as well, so each row takes its own +value in each future. A row with no scenario axis in its total, such as the +transmission volume, repeats the same capacity sum under each scenario's +constant. A plain run feeds one scenario, and the rows collapse to the standard +ones. + +The rung puts an extendable line under a volume limit of `60` in the calm +future and `20` in the stormy one, and a CO2 row that is at most `250` in the +calm future and exactly `200` in the stormy one. All three bind. With the same +network and the calm values in both futures, PyPSA solves to +`12943.333333333334`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit), [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) with a constant and sense per scenario | done | `GlobalConstraint_constant` and `GlobalConstraint_sense` over `scenario` | +| `transmission_expansion_cost_limit` on a network with scenarios | out | PyPSA `1.3.0` builds no row: it matches extendable names against a table indexed by scenario and name, and finds none (`global_constraints.py:916`). The file builds the row per scenario | +| `transmission_volume_expansion_limit` on a network with scenarios and `multi_investment_periods` | out | PyPSA `1.3.0` builds no row: the active-asset filter reindexes a table indexed by scenario and name by the names alone, and keeps none (`global_constraints.py:828`, `descriptors.py:261-263`). The file builds the row per scenario | +| a `carrier_attribute` or `investment_period` per scenario | out | PyPSA reads both per scenario (`global_constraints.py:797-802`); the file states both per row, in the weights and in `GlobalConstraint_counts_snapshot` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `15106.666666666666`, 86 rows. + +
+The network, as PyPSA code + +`rung_40_scenario_global_constraints.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 40: a global constraint takes its own constant and sense in each scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own constant and sense, per row +PER_SCENARIO = { + ('calm', 'volume40'): {'constant': 60}, + ('stormy', 'volume40'): {'constant': 20}, + ('calm', 'co2_40'): {'constant': 250, 'sense': '<='}, + ('stormy', 'co2_40'): {'constant': 200, 'sense': '=='}, +} + + +def build(): + """The spine over two futures, with an extendable line under a volume limit and a CO2 row that differ by scenario.""" + n = spine.build() + n.add('Carrier', 'AC') + n.add('Carrier', 'coalc', co2_emissions=0.5) + n.c.generators.static.loc['coal', 'carrier'] = 'coalc' + n.add( + 'Line', + 'tie40', + bus0='north', + bus1='south', + x=0.1, + carrier='AC', + length=2, + s_nom_extendable=True, + capital_cost=1, + ) + n.add('Load', 'port40', bus='south', p_set=30) + n.add( + 'GlobalConstraint', + 'volume40', + type='transmission_volume_expansion_limit', + carrier_attribute='AC', + sense='<=', + constant=60, + ) + n.add( + 'GlobalConstraint', 'co2_40', type='primary_energy', carrier_attribute='co2_emissions', sense='<=', constant=250 + ) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for row, values in PER_SCENARIO.items(): + for column, value in values.items(): + n.c.global_constraints.static.loc[row, column] = value + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3611,8 +3705,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{a}^{\sigma}`$ | `Transformer_loss_slope` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep | | $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | -| $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\mathcal{I}`$ — which way the row binds — `<=`, `>=` or `==` | -| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep | +| $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\Xi \times \mathcal{I}`$ — which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA reads a row's sense per scenario (`global_constraints.py:556`, `:748`, `:860`) | +| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\Xi \times \mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep. PyPSA reads it per scenario (`global_constraints.py:557`, `:749`, `:861`) | | $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | | $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | | $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | @@ -7474,7 +7568,7 @@ GlobalConstraint_primary_energy_ub: ``` ```math -\mathit{primary\_energy}_{\xi,i} \le \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} +\mathit{primary\_energy}_{\xi,i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `primary_energy` @@ -7490,7 +7584,7 @@ GlobalConstraint_primary_energy_lb: ``` ```math -\mathit{primary\_energy}_{\xi,i} \ge \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} +\mathit{primary\_energy}_{\xi,i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `primary_energy` @@ -7506,7 +7600,7 @@ GlobalConstraint_primary_energy_eq: ``` ```math -\mathit{primary\_energy}_{\xi,i} = \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} +\mathit{primary\_energy}_{\xi,i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `operational_limit` @@ -7522,7 +7616,7 @@ GlobalConstraint_operational_limit_ub: ``` ```math -\mathit{operational\_limit}_{\xi,i} \le \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} +\mathit{operational\_limit}_{\xi,i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `operational_limit` @@ -7538,7 +7632,7 @@ GlobalConstraint_operational_limit_lb: ``` ```math -\mathit{operational\_limit}_{\xi,i} \ge \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} +\mathit{operational\_limit}_{\xi,i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `operational_limit` @@ -7554,7 +7648,7 @@ GlobalConstraint_operational_limit_eq: ``` ```math -\mathit{operational\_limit}_{\xi,i} = \mathrm{K}_{i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} +\mathit{operational\_limit}_{\xi,i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -7564,13 +7658,13 @@ GlobalConstraint_operational_limit_eq: ```yaml GlobalConstraint_transmission_volume_expansion_limit_ub: description: "`transmission_volume_expansion_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '<=' expression: transmission_volume_expansion <= GlobalConstraint_constant ``` ```math -\mathit{transmission\_volume\_expansion}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_volume\_expansion}_{i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -7580,13 +7674,13 @@ GlobalConstraint_transmission_volume_expansion_limit_ub: ```yaml GlobalConstraint_transmission_volume_expansion_limit_lb: description: "`transmission_volume_expansion_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '>=' expression: transmission_volume_expansion >= GlobalConstraint_constant ``` ```math -\mathit{transmission\_volume\_expansion}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_volume\_expansion}_{i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -7596,13 +7690,13 @@ GlobalConstraint_transmission_volume_expansion_limit_lb: ```yaml GlobalConstraint_transmission_volume_expansion_limit_eq: description: "`transmission_volume_expansion_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '==' expression: transmission_volume_expansion == GlobalConstraint_constant ``` ```math -\mathit{transmission\_volume\_expansion}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_volume\_expansion}_{i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7612,13 +7706,13 @@ GlobalConstraint_transmission_volume_expansion_limit_eq: ```yaml GlobalConstraint_transmission_expansion_cost_limit_ub: description: "`transmission_expansion_cost_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '<=' expression: transmission_expansion_cost <= GlobalConstraint_constant ``` ```math -\mathit{transmission\_expansion\_cost}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_expansion\_cost}_{i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7628,13 +7722,13 @@ GlobalConstraint_transmission_expansion_cost_limit_ub: ```yaml GlobalConstraint_transmission_expansion_cost_limit_lb: description: "`transmission_expansion_cost_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '>=' expression: transmission_expansion_cost >= GlobalConstraint_constant ``` ```math -\mathit{transmission\_expansion\_cost}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_expansion\_cost}_{i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7644,13 +7738,13 @@ GlobalConstraint_transmission_expansion_cost_limit_lb: ```yaml GlobalConstraint_transmission_expansion_cost_limit_eq: description: "`transmission_expansion_cost_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '==' expression: transmission_expansion_cost == GlobalConstraint_constant ``` ```math -\mathit{transmission\_expansion\_cost}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_expansion\_cost}_{i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -7660,13 +7754,13 @@ GlobalConstraint_transmission_expansion_cost_limit_eq: ```yaml GlobalConstraint_tech_capacity_expansion_limit_ub: description: "`tech_capacity_expansion_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '<=' expression: tech_capacity_expansion <= GlobalConstraint_constant ``` ```math -\mathit{tech\_capacity\_expansion}_{i} \le \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{<=}\text{'} +\mathit{tech\_capacity\_expansion}_{i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -7676,13 +7770,13 @@ GlobalConstraint_tech_capacity_expansion_limit_ub: ```yaml GlobalConstraint_tech_capacity_expansion_limit_lb: description: "`tech_capacity_expansion_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '>=' expression: tech_capacity_expansion >= GlobalConstraint_constant ``` ```math -\mathit{tech\_capacity\_expansion}_{i} \ge \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{>=}\text{'} +\mathit{tech\_capacity\_expansion}_{i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -7692,13 +7786,13 @@ GlobalConstraint_tech_capacity_expansion_limit_lb: ```yaml GlobalConstraint_tech_capacity_expansion_limit_eq: description: "`tech_capacity_expansion_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '==' expression: tech_capacity_expansion == GlobalConstraint_constant ``` ```math -\mathit{tech\_capacity\_expansion}_{i} = \mathrm{K}_{i} \qquad \forall\, i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{i} = \text{'}\mathrm{==}\text{'} +\mathit{tech\_capacity\_expansion}_{i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `Bus-nodal_balance` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 3bdaeede..a58374b9 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -948,16 +948,20 @@ parameters: dims: [global_constraint] dtype: str GlobalConstraint_sense: - description: which way the row binds — `<=`, `>=` or `==` - dims: [global_constraint] + description: >- + which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA + reads a row's sense per scenario (`global_constraints.py:556`, `:748`, + `:860`) + dims: [scenario, global_constraint] dtype: str GlobalConstraint_constant: description: >- the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is - folded in here by data prep - dims: [global_constraint] + folded in here by data prep. PyPSA reads it per scenario + (`global_constraints.py:557`, `:749`, `:861`) + dims: [scenario, global_constraint] GlobalConstraint_counts_snapshot: description: >- whether a row counts a snapshot — PyPSA's `investment_period`: every @@ -3500,47 +3504,47 @@ constraints: expression: operational_limit == GlobalConstraint_constant GlobalConstraint_transmission_volume_expansion_limit_ub: description: "`transmission_volume_expansion_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '<=' expression: transmission_volume_expansion <= GlobalConstraint_constant GlobalConstraint_transmission_volume_expansion_limit_lb: description: "`transmission_volume_expansion_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '>=' expression: transmission_volume_expansion >= GlobalConstraint_constant GlobalConstraint_transmission_volume_expansion_limit_eq: description: "`transmission_volume_expansion_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '==' expression: transmission_volume_expansion == GlobalConstraint_constant GlobalConstraint_transmission_expansion_cost_limit_ub: description: "`transmission_expansion_cost_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '<=' expression: transmission_expansion_cost <= GlobalConstraint_constant GlobalConstraint_transmission_expansion_cost_limit_lb: description: "`transmission_expansion_cost_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '>=' expression: transmission_expansion_cost >= GlobalConstraint_constant GlobalConstraint_transmission_expansion_cost_limit_eq: description: "`transmission_expansion_cost_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '==' expression: transmission_expansion_cost == GlobalConstraint_constant GlobalConstraint_tech_capacity_expansion_limit_ub: description: "`tech_capacity_expansion_limit` — its total, at most its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '<=' expression: tech_capacity_expansion <= GlobalConstraint_constant GlobalConstraint_tech_capacity_expansion_limit_lb: description: "`tech_capacity_expansion_limit` — its total, at least its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '>=' expression: tech_capacity_expansion >= GlobalConstraint_constant GlobalConstraint_tech_capacity_expansion_limit_eq: description: "`tech_capacity_expansion_limit` — its total, at its constant" - dims: [global_constraint] + dims: [scenario, global_constraint] where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '==' expression: tech_capacity_expansion == GlobalConstraint_constant Bus_nodal_balance: diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 2cf096b6..a68d6c36 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1551,5 +1551,46 @@ "Store-ext-e_nom-lower": 2, "Store-ext-e_nom-upper": 2 } + }, + "rung_40_scenario_global_constraints": { + "columns": { + "Generator-p": 16, + "Line-s": 8, + "Line-s_nom": 1, + "Link-p": 8 + }, + "global_constraints": { + "('calm', 'co2_40')": { + "sense": "<=", + "type": "primary_energy" + }, + "('calm', 'volume40')": { + "sense": "<=", + "type": "transmission_volume_expansion_limit" + }, + "('stormy', 'co2_40')": { + "sense": "==", + "type": "primary_energy" + }, + "('stormy', 'volume40')": { + "sense": "<=", + "type": "transmission_volume_expansion_limit" + } + }, + "objective": 15106.666666666666, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 16, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "GlobalConstraint-co2_40": 2, + "GlobalConstraint-volume40": 2, + "Line-ext-s-lower": 8, + "Line-ext-s-upper": 8, + "Line-ext-s_nom-lower": 2, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_40_scenario_global_constraints.py b/examples/references/pypsa/rung_40_scenario_global_constraints.py new file mode 100644 index 00000000..5dd1679e --- /dev/null +++ b/examples/references/pypsa/rung_40_scenario_global_constraints.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 40: a global constraint takes its own constant and sense in each scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own constant and sense, per row +PER_SCENARIO = { + ('calm', 'volume40'): {'constant': 60}, + ('stormy', 'volume40'): {'constant': 20}, + ('calm', 'co2_40'): {'constant': 250, 'sense': '<='}, + ('stormy', 'co2_40'): {'constant': 200, 'sense': '=='}, +} + + +def build(): + """The spine over two futures, with an extendable line under a volume limit and a CO2 row that differ by scenario.""" + n = spine.build() + n.add('Carrier', 'AC') + n.add('Carrier', 'coalc', co2_emissions=0.5) + n.c.generators.static.loc['coal', 'carrier'] = 'coalc' + n.add( + 'Line', + 'tie40', + bus0='north', + bus1='south', + x=0.1, + carrier='AC', + length=2, + s_nom_extendable=True, + capital_cost=1, + ) + n.add('Load', 'port40', bus='south', p_set=30) + n.add( + 'GlobalConstraint', + 'volume40', + type='transmission_volume_expansion_limit', + carrier_attribute='AC', + sense='<=', + constant=60, + ) + n.add( + 'GlobalConstraint', 'co2_40', type='primary_energy', carrier_attribute='co2_emissions', sense='<=', constant=250 + ) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for row, values in PER_SCENARIO.items(): + for column, value in values.items(): + n.c.global_constraints.static.loc[row, column] = value + return n diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index 344a8561..c91d5b3d 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -75,13 +75,17 @@ def test_a_standard_constraint_changes_frame_only_by_a_leading_scenario(): assert not wrong, f'a standard row may gain only a leading scenario; these differ: {wrong}' -def test_only_load_carries_scenario_among_standard_parameters(): +#: the standard parameters PyPSA reads per scenario +SCENARIO_PARAMETERS = {'Load_p_set', 'GlobalConstraint_sense', 'GlobalConstraint_constant'} + + +def test_only_the_scenario_parameters_carry_scenario_among_standard_parameters(): wrong = { name: _dims(ALL.parameters[name]) for name, dims in STANDARD['parameters'].items() - if _dims(ALL.parameters[name]) != (['scenario', *dims] if name == 'Load_p_set' else dims) + if _dims(ALL.parameters[name]) != (['scenario', *dims] if name in SCENARIO_PARAMETERS else dims) } - assert not wrong, f'only Load_p_set gains scenario; these differ: {wrong}' + assert not wrong, f'only {sorted(SCENARIO_PARAMETERS)} gain scenario; these differ: {wrong}' def test_the_extra_axes_and_rows_are_declared(): diff --git a/tests/test_pypsa_references.py b/tests/test_pypsa_references.py index aae55ab7..d33c9cf7 100644 --- a/tests/test_pypsa_references.py +++ b/tests/test_pypsa_references.py @@ -148,9 +148,9 @@ def test_the_spine_weightings_are_generic(): assert 1.0 not in values, f'{name} carries a 1.0 — the identity a missing factor hides behind' -@pytest.mark.parametrize('row', sorted(row for row in RECORDED_ROWS if row.startswith('GlobalConstraint-')), ids=str) -def test_a_global_constraint_row_has_a_block_of_its_recorded_type_and_sense(row: str): - gc = GC_RECORDED[row.removeprefix('GlobalConstraint-')] +@pytest.mark.parametrize('label', sorted(GC_RECORDED), ids=str) +def test_a_global_constraint_row_has_a_block_of_its_recorded_type_and_sense(label: str): + gc = GC_RECORDED[label] matching = [ name for m in MODELS From 0db274a24a104652ef6498bffce8897299d4c557 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 24 Sep 2026 16:42:46 +0200 Subject: [PATCH 30/75] docs(pypsa): the stochastic rung says which component data spans a scenario --- docs/examples/pypsa.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index cc57953b..5bc57de5 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1299,6 +1299,14 @@ excess per scenario and the tail's average, blended into the objective at (`optimize.py:458`). The file builds them only where `omega` is positive, so a plain run, and a risk preference with `omega = 0`, has none. +Component data does not span a scenario yet. PyPSA reads almost every +attribute per scenario, and refuses a difference only in the ones that fix +the network's shape, such as `bus`, `carrier`, `lifetime` or +`p_nom_extendable` (`consistency.py:1174-1195`). The file gives a scenario +axis only to a load's `p_set` and a global constraint's `sense` and +`constant`. This rung's wind `p_max_pu` differs by scenario, so the file +states the rung's rows but not all of its data. + | PyPSA | status | note | | --- | --- | --- | | [`Generator-p`, `Link-p`](#variable-domains) | done | over `scenario`; `Generator-p_nom` is not — chosen once | @@ -1307,6 +1315,7 @@ plain run, and a risk preference with `omega = 0`, has none. | [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension; none where `omega` is zero | | [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep; none where `omega` is zero | | [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | +| `Generator-p_max_pu` per scenario | out | the file holds one availability for every scenario; only a load's `p_set` spans one | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. From e52b90d26e3a9acd8246633cd461fa8c793814db Mon Sep 17 00:00:00 2001 From: Fabian Hofmann Date: Thu, 24 Sep 2026 17:09:22 +0200 Subject: [PATCH 31/75] docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario (#688) * docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario * docs(pypsa): two rungs show operating and first-stage data that differ by scenario --- docs/examples/pypsa.md | 1191 +++++++++-------- examples/pypsa.yaml | 556 ++++---- examples/references/pypsa/references.json | 39 + .../rung_41_scenario_operational_data.py | 31 + .../pypsa/rung_42_scenario_first_stage.py | 26 + tests/test_pypsa_collapses.py | 23 +- 6 files changed, 1073 insertions(+), 793 deletions(-) create mode 100644 examples/references/pypsa/rung_41_scenario_operational_data.py create mode 100644 examples/references/pypsa/rung_42_scenario_first_stage.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5bc57de5..ea99fdb6 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1299,13 +1299,22 @@ excess per scenario and the tail's average, blended into the objective at (`optimize.py:458`). The file builds them only where `omega` is positive, so a plain run, and a risk preference with `omega = 0`, has none. -Component data does not span a scenario yet. PyPSA reads almost every -attribute per scenario, and refuses a difference only in the ones that fix -the network's shape, such as `bus`, `carrier`, `lifetime` or -`p_nom_extendable` (`consistency.py:1174-1195`). The file gives a scenario -axis only to a load's `p_set` and a global constraint's `sense` and -`constant`. This rung's wind `p_max_pu` differs by scenario, so the file -states the rung's rows but not all of its data. +A parameter spans `scenario` exactly when PyPSA reads it per scenario. PyPSA +reads component data through `c.da`, one value per scenario +(`components/array.py:332-395`), so almost every parameter spans one. It +refuses a difference in the attributes that fix the network's shape, such as +`bus`, `carrier`, `lifetime`, `active`, `committable` or `p_nom_extendable` +(`consistency.py:1174-1195`), and these parameters and what data prep derives +from them span none. Some other parameters span none either. PyPSA reduces +`maintainable` to a union over the scenarios, `(p_min_pu >= 0).all()` over +them, and a carrier's growth limits to their least value +(`components.py:1016-1019`, `constraints.py:397-401`, +`global_constraints.py:226-230`). It builds the cycle basis from the first +scenario (`networks.py:1354-1361`). A link's delay and a transformer's phase +shift span none, because PyPSA `1.3.0` mishandles them over scenarios (rung +41). A branch's `BODF` spans none, because PyPSA refuses a +security-constrained run over scenarios. This rung's wind `p_max_pu` differs by scenario, and the file states it over +`scenario`. Rungs 41 and 42 make operating data and first-stage data differ. | PyPSA | status | note | | --- | --- | --- | @@ -1314,8 +1323,8 @@ states the rung's rows but not all of its data. | [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | | [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension; none where `omega` is zero | | [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep; none where `omega` is zero | -| [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | -| `Generator-p_max_pu` per scenario | out | the file holds one availability for every scenario; only a load's `p_set` spans one | +| [objective](#objective) | done | capacity once, at its capital cost in expectation over the scenarios; operation `(1 - omega)` in expectation, `omega` at the tail | +| `Generator-p_max_pu` and other component data per scenario | done | every parameter PyPSA reads per scenario spans `scenario`; operating data in rung 41, first-stage bounds and capital cost in rung 42 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `9267.386666666665`, 87 rows. @@ -3366,7 +3375,7 @@ network and the calm values in both futures, PyPSA solves to | [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit), [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) with a constant and sense per scenario | done | `GlobalConstraint_constant` and `GlobalConstraint_sense` over `scenario` | | `transmission_expansion_cost_limit` on a network with scenarios | out | PyPSA `1.3.0` builds no row: it matches extendable names against a table indexed by scenario and name, and finds none (`global_constraints.py:916`). The file builds the row per scenario | | `transmission_volume_expansion_limit` on a network with scenarios and `multi_investment_periods` | out | PyPSA `1.3.0` builds no row: the active-asset filter reindexes a table indexed by scenario and name by the names alone, and keeps none (`global_constraints.py:828`, `descriptors.py:261-263`). The file builds the row per scenario | -| a `carrier_attribute` or `investment_period` per scenario | out | PyPSA reads both per scenario (`global_constraints.py:797-802`); the file states both per row, in the weights and in `GlobalConstraint_counts_snapshot` | +| a `carrier_attribute` or `investment_period` per scenario | done | PyPSA reads both per scenario (`global_constraints.py:797-802`); the weights and `GlobalConstraint_counts_snapshot` span `scenario` | > ✔ `pypsa 1.3.0` solves this rung's network at objective `15106.666666666666`, 86 rows. @@ -3435,6 +3444,140 @@ def build(): +### Rung 41 — operating data per scenario + +`n.set_scenarios(...)` with a gas unit's `marginal_cost` and a link's +`efficiency` set per scenario. PyPSA reads both through `c.da`, one value per +scenario, into the objective and into `Bus-nodal_balance` +(`components/array.py:332-395`). The file states `Generator_marginal_cost` and +`Link_efficiency` over `scenario`, as it states every parameter PyPSA reads per +scenario. A plain run feeds one scenario, and the rows collapse to the standard +ones. + +The rung adds a gas unit that costs `20` in the calm future and `80` in the +stormy one, and a second link that delivers `0.9` and `0.6` of its flow. Each +binds. With the calm cost in both futures, PyPSA solves to `16308.0`; with the +calm efficiency in both, to `16992.0`; with both calm values in both, to +`15660.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [objective](#objective), [`Bus-nodal_balance`](#bus-nodal_balance) with a cost and an efficiency per scenario | done | `Generator_marginal_cost` and `Link_efficiency` over `scenario` | +| a link `delay` or `cyclic_delay` that differs by scenario | out | PyPSA `1.3.0` groups the ports by delay over all scenarios and shifts each group in every scenario, so a delay of `0` in one future and `1` in the other solves below both uniform networks (`constraints.py:1269`). The file holds one delay for every scenario | +| a transformer in a cycle on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the transformers of a cycle by name from a table indexed by scenario and name (`constraints.py:1654`). The file holds one phase shift for every scenario | +| a committable component on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the status by snapshot and name where the first dimension is the scenario (`constraints.py:1872`, `:1942`). The file builds the rows per scenario | +| [`{c}-p_nom_set`](#generator-p_nom_set) on a network with scenarios | out | PyPSA `1.3.0` fails: it reindexes the build by a table indexed by scenario and name (`constraints.py:1708`). The file builds the row per scenario | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `17964.0`, 96 rows. + +
+The network, as PyPSA code + +`rung_41_scenario_operational_data.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 41: a unit's cost and a link's efficiency differ by scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own value, per component and attribute +PER_SCENARIO = { + ('calm', 'gas41'): {'marginal_cost': 20}, + ('stormy', 'gas41'): {'marginal_cost': 80}, + ('calm', 'wire41'): {'efficiency': 0.9}, + ('stormy', 'wire41'): {'efficiency': 0.6}, +} + + +def build(): + """The spine over two futures, with a gas unit that costs more and a link that delivers less in the stormy one.""" + n = spine.build() + n.add('Generator', 'gas41', bus='south', p_nom=100, marginal_cost=20) + n.add('Link', 'wire41', bus0='north', bus1='south', p_nom=40, efficiency=0.9) + n.add('Load', 'port41', bus='south', p_set=60) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for (scenario, name), values in PER_SCENARIO.items(): + component = n.c.generators if name == 'gas41' else n.c.links + for column, value in values.items(): + component.static.loc[(scenario, name), column] = value + return n +``` + +
+ + +### Rung 42 — first-stage data per scenario + +`n.set_scenarios(...)` with an extendable unit whose `capital_cost` and +`p_nom_max` differ between the scenarios. The build is chosen once, but PyPSA +reads its bounds per scenario and writes `Generator-ext-p_nom-lower` and +`-upper` once per scenario, so the tightest cap binds +(`constraints.py:885-895`). It prices the build at each scenario's capital +cost and weights the terms by the scenario weights (`optimize.py:405-412`, +`:448-454`), so the build pays its capital cost in expectation. The file states +`Generator_p_nom_max` and `Generator_capital_cost` over `scenario`, the bound +rows over `scenario`, and the capital terms of the objective under +`scenario_weight`. A plain run feeds one scenario of weight one, and the rows +and the objective collapse to the standard ones. + +The rung's wind unit costs `20` in the calm future and `60` in the stormy one, +weighted `0.6` and `0.4`, and may be built to `100` and `30`. PyPSA builds `30` +at the expected cost of `36`: the same network with a cost of `36` and a cap of +`30` in both futures solves to the same objective. With the calm values in both +futures, PyPSA solves to `1943.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-ext-p_nom-lower/upper`](#generator-ext-p_nom-lower) with a bound per scenario | done | one row per scenario, over `scenario`; the tightest binds | +| [capital cost](#objective) per scenario | done | `scenario_weight` times each scenario's capital cost | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `5049.999999999999`, 84 rows. + +
+The network, as PyPSA code + +`rung_42_scenario_first_stage.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 42: an extendable unit's capital cost and build cap differ by scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own value, per attribute of the extendable unit +PER_SCENARIO = { + 'calm': {'capital_cost': 20, 'p_nom_max': 100}, + 'stormy': {'capital_cost': 60, 'p_nom_max': 30}, +} + + +def build(): + """The spine over two futures, with an extendable wind unit that costs more and may be built less in the stormy one.""" + n = spine.build() + n.add('Generator', 'wind42', bus='south', p_nom_extendable=True, p_nom_max=100, marginal_cost=1, capital_cost=20) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for scenario, values in PER_SCENARIO.items(): + for column, value in values.items(): + n.c.generators.static.loc[(scenario, 'wind42'), column] = value + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3466,19 +3609,19 @@ concatenation of the regime blocks, `p0`/`p1` derived from `Link-p`. ## The file -A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. Capacity is chosen once, before the future is known, and paid once per active period; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows, which stand only where that share is positive. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. +A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year and lifetime span. A parameter spans `scenario` exactly when PyPSA reads it per scenario. Capacity is chosen once, before the future is known, and paid once per active period at its cost in expectation over the scenarios; operation is the expectation over the scenarios' weights, with a share priced at the tail through the CVaR rows, which stand only where that share is positive. A plain run feeds one scenario, one period, all-active masks and unit weights, and the model collapses to the standard one. A security-constrained run copies each branch flow limit once per outage in an `outage` set that a plain run leaves empty. Which snapshots an asset is active in, a scenario's weight, and the outage factors are data prep. #### Sets | Symbol | Meaning | |---|---| -| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y},\ \mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Link\_maintenance\_cover} \subseteq \mathcal{L} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Process\_maintenance\_cover} \subseteq \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | +| $`\Xi`$ | index $`\xi`$ — `scenario` with $`\mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y},\ \mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T},\ \mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N},\ \mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — generating units, each on one bus | -| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_maintenance\_cover} \subseteq \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — generating units, each on one bus | +| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | -| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_maintenance\_cover} \subseteq \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | | $`\mathcal{R}`$ | index $`r`$ — `process_output` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — a process's ports, one label per port a process declares — PyPSA's `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number of ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | @@ -3497,101 +3640,101 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | Symbol | Meaning | |---|---| | $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | -| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | +| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\Xi \times \mathcal{G}`$ — nominal power | | $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$ — whether the nominal power is a decision | -| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | -| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | -| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | -| $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | +| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | +| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | +| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | +| $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | -| $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\mathcal{G}`$ — least snapshots a unit stays on once started | -| $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | -| $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | -| $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | -| $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\mathcal{G}`$ — cost of one start | -| $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\mathcal{G}`$ — cost of one stop | -| $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | +| $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | +| $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays off once stopped | +| $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\Xi \times \mathcal{G}`$ — cost of one start | +| $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\Xi \times \mathcal{G}`$ — cost of one stop | +| $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | | $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | -| $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}`$ | `Generator_big_m` over $`\mathcal{G}`$ — a bound safely above any feasible output — the build cap at full availability, data prep | -| $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | -| $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon | -| $`\gamma`$ | `Generator_maintenance_pu` over $`\mathcal{G}`$ — the share of the build a maintenance event takes off | -| $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\mathcal{G}`$ — how many maintenance events the horizon holds | -| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | -| $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | -| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | +| $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\Xi \times \mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$ — a bound safely above any feasible output — the build cap at full availability, data prep | +| $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\Xi \times \mathcal{G}`$ — how many maintenance events the horizon holds | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | +| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\Xi \times \mathcal{L}`$ — nominal power | | $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$ — whether the nominal power is a decision | -| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | -| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once | -| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost | -| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | -| $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | +| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | +| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | +| $`\eta`$ | `Link_efficiency` over $`\Xi \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | +| $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. One for every scenario: PyPSA groups the ports by delay over all scenarios and shifts each group in every one (`constraints.py:1269`) | +| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. One for every scenario, as the delay | +| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | +| $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | -| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | -| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | -| $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\mathcal{L}`$ — least snapshots a link stays on once started | -| $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\mathcal{L}`$ — least snapshots a link stays off once stopped | -| $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | -| $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | -| $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\mathcal{L}`$ — cost of one start | -| $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\mathcal{L}`$ — cost of one stop | -| $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | +| $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays on once started | +| $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays off once stopped | +| $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\Xi \times \mathcal{L}`$ — cost of one start | +| $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\Xi \times \mathcal{L}`$ — cost of one stop | +| $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | | $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | -| $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\mathcal{L}`$ — a bound safely above any feasible flow — the build cap at full availability, data prep | -| $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | -| $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon | -| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\mathcal{L}`$ — the share of the build a maintenance event takes off | -| $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\mathcal{L}`$ — how many maintenance events the horizon holds | -| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | -| $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | -| $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\mathcal{J}`$ — nominal internal power | +| $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\Xi \times \mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$ — a bound safely above any feasible flow — the build cap at full availability, data prep | +| $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\Xi \times \mathcal{L}`$ — how many maintenance events the horizon holds | +| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | +| $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\Xi \times \mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | -| $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | -| $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | -| $`\alpha`$ | `Process_rate` over $`\mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | -| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once | -| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost | -| $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | -| $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | -| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | -| $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\mathcal{J}`$ — least nominal power an extendable process may be built at | -| $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\mathcal{J}`$ — most nominal power an extendable process may be built at | -| $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\mathcal{J}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | +| $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | +| $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | +| $`\alpha`$ | `Process_rate` over $`\Xi \times \mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | +| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once. One for every scenario, as a link's | +| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. One for every scenario, as the delay | +| $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | +| $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | +| $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$ — least nominal power an extendable process may be built at | +| $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$ — most nominal power an extendable process may be built at | +| $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\Xi \times \mathcal{J}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | | $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$ — whether internal power is gated by an on/off status decision | -| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | -| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | -| $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\mathcal{J}`$ — least snapshots a process stays on once started | -| $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\mathcal{J}`$ — least snapshots a process stays off once stopped | -| $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | -| $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | -| $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\mathcal{J}`$ — cost of one start | -| $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\mathcal{J}`$ — cost of one stop | -| $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | +| $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays on once started | +| $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays off once stopped | +| $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\Xi \times \mathcal{J}`$ — cost of one start | +| $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\Xi \times \mathcal{J}`$ — cost of one stop | +| $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | | $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | -| $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\mathcal{J}`$ — a bound safely above any feasible internal power — the build cap at full availability, data prep | -| $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep | -| $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon | -| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\mathcal{J}`$ — the share of the build a maintenance event takes off | -| $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\mathcal{J}`$ — how many maintenance events the horizon holds | -| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | -| $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | +| $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\Xi \times \mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$ — a bound safely above any feasible internal power — the build cap at full availability, data prep | +| $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\Xi \times \mathcal{J}`$ — how many maintenance events the horizon holds | +| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | @@ -3618,115 +3761,115 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit | -| $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top | -| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | -| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep | +| $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | +| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | +| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | | $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | | $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total | -| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\mathcal{G}`$ — least nominal power an extendable generator may be built at | -| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\mathcal{G}`$ — most nominal power an extendable generator may be built at | -| $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\mathcal{G}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\mathcal{G}`$ — a given nominal power for an extendable generator; one without a value has no row here | -| $`\underline{\mathrm{E}}`$ | `Generator_e_sum_min` over $`\mathcal{G}`$ — least energy over the horizon; minus infinity where no floor is meant | -| $`\overline{\mathrm{E}}`$ | `Generator_e_sum_max` over $`\mathcal{G}`$ — most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant | -| $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\mathcal{L}`$ — least nominal power an extendable link may be built at | -| $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\mathcal{L}`$ — most nominal power an extendable link may be built at | -| $`\mathrm{c}^{\mathrm{cap},f}`$ | `Link_capital_cost` over $`\mathcal{L}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\mathcal{L}`$ — a given nominal power for an extendable link; one without a value has no row here | -| $`\underline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_min` over $`\mathcal{S}`$ — least nominal power an extendable storage unit may be built at | -| $`\overline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_max` over $`\mathcal{S}`$ — most nominal power an extendable storage unit may be built at | -| $`\mathrm{c}^{\mathrm{cap},h}`$ | `StorageUnit_capital_cost` over $`\mathcal{S}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\mathcal{S}`$ — a given nominal power for an extendable storage unit; one without a value has no row here | -| $`\underline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_min` over $`\mathcal{V}`$ — least nominal capacity an extendable store may be built at | -| $`\overline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_max` over $`\mathcal{V}`$ — most nominal capacity an extendable store may be built at | -| $`\mathrm{c}^{\mathrm{cap},e}`$ | `Store_capital_cost` over $`\mathcal{V}`$ — cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\mathcal{V}`$ — a given nominal capacity for an extendable store; one without a value has no row here | -| $`\mathrm{h}^{\mathrm{nom}}`$ | `StorageUnit_p_nom` over $`\mathcal{S}`$ — nominal power | +| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\Xi \times \mathcal{G}`$ — least nominal power an extendable generator may be built at | +| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\Xi \times \mathcal{G}`$ — most nominal power an extendable generator may be built at | +| $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\Xi \times \mathcal{G}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$ — a given nominal power for an extendable generator; one without a value has no row here | +| $`\underline{\mathrm{E}}`$ | `Generator_e_sum_min` over $`\Xi \times \mathcal{G}`$ — least energy over the horizon; minus infinity where no floor is meant | +| $`\overline{\mathrm{E}}`$ | `Generator_e_sum_max` over $`\Xi \times \mathcal{G}`$ — most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant | +| $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\Xi \times \mathcal{L}`$ — least nominal power an extendable link may be built at | +| $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\Xi \times \mathcal{L}`$ — most nominal power an extendable link may be built at | +| $`\mathrm{c}^{\mathrm{cap},f}`$ | `Link_capital_cost` over $`\Xi \times \mathcal{L}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$ — a given nominal power for an extendable link; one without a value has no row here | +| $`\underline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_min` over $`\Xi \times \mathcal{S}`$ — least nominal power an extendable storage unit may be built at | +| $`\overline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_max` over $`\Xi \times \mathcal{S}`$ — most nominal power an extendable storage unit may be built at | +| $`\mathrm{c}^{\mathrm{cap},h}`$ | `StorageUnit_capital_cost` over $`\Xi \times \mathcal{S}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$ — a given nominal power for an extendable storage unit; one without a value has no row here | +| $`\underline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_min` over $`\Xi \times \mathcal{V}`$ — least nominal capacity an extendable store may be built at | +| $`\overline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_max` over $`\Xi \times \mathcal{V}`$ — most nominal capacity an extendable store may be built at | +| $`\mathrm{c}^{\mathrm{cap},e}`$ | `Store_capital_cost` over $`\Xi \times \mathcal{V}`$ — cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$ — a given nominal capacity for an extendable store; one without a value has no row here | +| $`\mathrm{h}^{\mathrm{nom}}`$ | `StorageUnit_p_nom` over $`\Xi \times \mathcal{S}`$ — nominal power | | $`\mathrm{ext}^{h}`$ | `StorageUnit_p_nom_extendable` over $`\mathcal{S}`$ — whether the nominal power is a decision | -| $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | -| $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | -| $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | -| $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\mathcal{S}`$ — share of the power drawn from the bus that becomes charge | -| $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\mathcal{S}`$ — share of the charge drawn down that reaches the bus | -| $`\rho`$ | `StorageUnit_retention` over $`\mathcal{T} \times \mathcal{S}`$ — share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | -| $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | -| $`\mathrm{soc}^{0}`$ | `StorageUnit_state_of_charge_initial` over $`\mathcal{S}`$ — charge held before the first snapshot | -| $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | -| $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | -| $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | +| $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | +| $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | +| $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\Xi \times \mathcal{S}`$ — share of the power drawn from the bus that becomes charge | +| $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\Xi \times \mathcal{S}`$ — share of the charge drawn down that reaches the bus | +| $`\rho`$ | `StorageUnit_retention` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | +| $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | +| $`\mathrm{soc}^{0}`$ | `StorageUnit_state_of_charge_initial` over $`\Xi \times \mathcal{S}`$ — charge held before the first snapshot | +| $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\Xi \times \mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | +| $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every unit stands throughout | | $`\mathrm{idle}`$ | `StorageUnit_inactive_snapshots` over $`\mathcal{S}`$ — how many snapshots a storage unit does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic unit reaches back this many snapshots further, so it closes on the last snapshot it stands in | -| $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | -| $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | -| $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | -| $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | -| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | -| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | -| $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\mathcal{V}`$ — nominal energy capacity | +| $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | +| $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | +| $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | +| $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | +| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | +| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | +| $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\Xi \times \mathcal{V}`$ — nominal energy capacity | | $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | -| $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | -| $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | -| $`\rho^{e}`$ | `Store_retention` over $`\mathcal{T} \times \mathcal{V}`$ — share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | -| $`\mathrm{e}^{0}`$ | `Store_e_initial` over $`\mathcal{V}`$ — energy held before the first snapshot | -| $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | -| $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | -| $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | +| $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | +| $`\rho^{e}`$ | `Store_retention` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | +| $`\mathrm{e}^{0}`$ | `Store_e_initial` over $`\Xi \times \mathcal{V}`$ — energy held before the first snapshot | +| $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\Xi \times \mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | +| $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every store stands throughout | | $`\mathrm{idle}^{e}`$ | `Store_inactive_snapshots` over $`\mathcal{V}`$ — how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic store reaches back this many snapshots further, so it closes on the last snapshot it stands in | -| $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | -| $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | -| $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | -| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | -| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | -| $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\mathcal{K}`$ — nominal apparent power | +| $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | +| $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | +| $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | +| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | +| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | +| $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\Xi \times \mathcal{K}`$ — nominal apparent power | | $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | -| $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | -| $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\mathcal{K}`$ — least nominal apparent power an extendable line may be built at | -| $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\mathcal{K}`$ — most nominal apparent power an extendable line may be built at | -| $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\mathcal{K}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | -| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | -| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row | +| $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | +| $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\Xi \times \mathcal{K}`$ — least nominal apparent power an extendable line may be built at | +| $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\Xi \times \mathcal{K}`$ — most nominal apparent power an extendable line may be built at | +| $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\Xi \times \mathcal{K}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | +| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | +| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) | | $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run | -| $`\overline{\ell}`$ | `Line_loss_max` over $`\mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | -| $`\mathrm{a}`$ | `Line_loss_slope` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | -| $`\mathrm{b}`$ | `Line_loss_offset` over $`\mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | -| $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\mathcal{M}`$ — nominal apparent power | +| $`\overline{\ell}`$ | `Line_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | +| $`\mathrm{a}`$ | `Line_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | +| $`\mathrm{b}`$ | `Line_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | +| $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\Xi \times \mathcal{M}`$ — nominal apparent power | | $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | -| $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | -| $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | -| $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | -| $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\mathcal{M}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | -| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | -| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | -| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row | +| $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | +| $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\Xi \times \mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | +| $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\Xi \times \mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | +| $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\Xi \times \mathcal{M}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | +| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | +| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | | $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | | $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | -| $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift` | +| $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift`. The shift parameters carry no scenario: only a cycle row reads them, and PyPSA fails on a transformer in a cycle on a network with scenarios (`constraints.py:1654`) | | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | | $`\mathrm{Transformer\_phase\_shift\_cycle\_weight}`$ | `Transformer_phase_shift_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the cycle sign for a varying transformer's phase shift, times π/180 so a shift in degrees enters the cycle sum in radians — data prep; zero for a fixed transformer or one in no cycle | -| $`\overline{\ell}^{\sigma}`$ | `Transformer_loss_max` over $`\mathcal{T} \times \mathcal{M}`$ — the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times the tap ratio, data prep | -| $`\mathrm{a}^{\sigma}`$ | `Transformer_loss_slope` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep | -| $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | +| $`\overline{\ell}^{\sigma}`$ | `Transformer_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times the tap ratio, data prep | +| $`\mathrm{a}^{\sigma}`$ | `Transformer_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep | +| $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{M} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\Xi \times \mathcal{I}`$ — which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA reads a row's sense per scenario (`global_constraints.py:556`, `:748`, `:860`) | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\Xi \times \mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep. PyPSA reads it per scenario (`global_constraints.py:557`, `:749`, `:861`) | -| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | -| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | -| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | -| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | -| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | -| $`\mathrm{len}`$ | `Line_volume_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row | -| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | +| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | +| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row | | $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | | $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | | $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | @@ -3793,46 +3936,46 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file |---|---| | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | -| $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | -| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | -| $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | -| $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | -| $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\mathcal{G}`$ — the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\Xi \times \mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\Xi \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\Xi \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\Xi \times \mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\Xi \times \mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\Xi \times \mathcal{G}`$ — the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | -| $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\Xi \times \mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — nothing at the start of the horizon, which is why a link that came in running carries no ramp row there | -| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | -| $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | -| $`\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}`$ | `Link_shut_down_rate` over $`\mathcal{L}`$ — the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | -| $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\mathcal{L}`$ — the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\Xi \times \mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\Xi \times \mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\Xi \times \mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}`$ | `Link_shut_down_rate` over $`\Xi \times \mathcal{L}`$ — the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\Xi \times \mathcal{L}`$ — the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{f,+}`$ | `Link_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may raise flow between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | -| $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\Xi \times \mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — nothing at the start of the horizon, which is why a process that came in running carries no ramp row there | -| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | -| $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | -| $`\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}`$ | `Process_shut_down_rate` over $`\mathcal{J}`$ — the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | -| $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\Xi \times \mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\Xi \times \mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\Xi \times \mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}`$ | `Process_shut_down_rate` over $`\Xi \times \mathcal{J}`$ — the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\Xi \times \mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | | $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial charge, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A unit built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed flow wraps from the period's end, and where it is not the flow still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | -| $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\mathcal{I} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | -| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | -| $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | -| $`\mathit{w}^{e}`$ | `Store_closing_weight` over $`\mathcal{I} \times \mathcal{T} \times \mathcal{V}`$ — what the energy a store holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | +| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | +| $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{w}^{e}`$ | `Store_closing_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{V}`$ — what the energy a store holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | | $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{I}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | -| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | -| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | @@ -3860,21 +4003,23 @@ $`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, a ```yaml objective: sense: minimize - description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + description: >- + capacity once per active period at its expected cost over the scenarios, operation in + expectation over the scenarios, and a share of it at the tail expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR ``` ```math -\min \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{g} \cdot \mathrm{W}_{g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{l} \cdot \mathrm{W}^{f}_{l} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{s} \cdot \mathrm{W}^{h}_{s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{v} \cdot \mathrm{W}^{e}_{v} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{k} \cdot \mathrm{W}^{s}_{k} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{j} \cdot \mathrm{W}^{z}_{j} + \sum_{m \in \mathcal{M}} \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +\min \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} + \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} + \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} + \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} + \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} + \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} + \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR ``` ### `Generator-fix-p-lower` @@ -3890,7 +4035,7 @@ Generator_fix_p_lower: ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \gamma_{\xi,g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-fix-p-upper` @@ -3906,7 +4051,7 @@ Generator_fix_p_upper: ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \gamma_{\xi,g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Link-fix-p-lower` @@ -3922,7 +4067,7 @@ Link_fix_p_lower: ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( 1 - \gamma^{f}_{l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \gamma^{f}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-fix-p-upper` @@ -3938,7 +4083,7 @@ Link_fix_p_upper: ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( 1 - \gamma^{f}_{l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \gamma^{f}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Generator-ext-p-lower` @@ -3954,7 +4099,7 @@ Generator_ext_p_lower: ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p-upper` @@ -3970,7 +4115,7 @@ Generator_ext_p_upper: ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-ext-p_nom-lower` @@ -3979,14 +4124,14 @@ p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdo ```yaml Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] + description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, generator] where: Generator_p_nom_extendable expression: Generator_p_nom_ext >= Generator_p_nom_min ``` ```math -P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} +P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} ``` ### `Generator-ext-p_nom-upper` @@ -3995,14 +4140,14 @@ P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \math ```yaml Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] + description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, generator] where: Generator_p_nom_extendable AND Generator_p_nom_max expression: Generator_p_nom_ext <= Generator_p_nom_max ``` ```math -P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \text{ is defined} +P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \text{ is defined} ``` ### `Generator-p_nom_set` @@ -4012,13 +4157,13 @@ P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \qquad \forall\, g \in \mathc ```yaml Generator_p_nom_set: description: "`Generator-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [generator] + dims: [scenario, generator] where: Generator_p_nom_extendable AND Generator_p_nom_set expression: Generator_p_nom_ext == Generator_p_nom_set ``` ```math -P_{g} = \mathrm{p}^{\mathrm{nom,set}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{nom,set}}_{g} \text{ is defined} +P_{g} = \mathrm{p}^{\mathrm{nom,set}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{nom,set}}_{\xi,g} \text{ is defined} ``` ### `Generator-e_sum_min` @@ -4034,7 +4179,7 @@ Generator_e_sum_min: ``` ```math -\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \ge \underline{\mathrm{E}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \underline{\mathrm{E}}_{g} \text{ is defined} +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \ge \underline{\mathrm{E}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \underline{\mathrm{E}}_{\xi,g} \text{ is defined} ``` ### `Generator-e_sum_max` @@ -4050,7 +4195,7 @@ Generator_e_sum_max: ``` ```math -\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \le \overline{\mathrm{E}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \overline{\mathrm{E}}_{g} \text{ is defined} +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \le \overline{\mathrm{E}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \overline{\mathrm{E}}_{\xi,g} \text{ is defined} ``` ### `Link-ext-p-lower` @@ -4066,7 +4211,7 @@ Link_ext_p_lower: ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p-upper` @@ -4082,7 +4227,7 @@ Link_ext_p_upper: ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-ext-p_nom-lower` @@ -4091,14 +4236,14 @@ f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} ```yaml Link_ext_p_nom_lower: - description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [link] + description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, link] where: Link_p_nom_extendable expression: Link_p_nom_ext >= Link_p_nom_min ``` ```math -F_{l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} +F_{l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} ``` ### `Link-ext-p_nom-upper` @@ -4107,14 +4252,14 @@ F_{l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, l \in \math ```yaml Link_ext_p_nom_upper: - description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [link] + description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, link] where: Link_p_nom_extendable AND Link_p_nom_max expression: Link_p_nom_ext <= Link_p_nom_max ``` ```math -F_{l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \text{ is defined} +F_{l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \text{ is defined} ``` ### `Link-p_nom_set` @@ -4124,13 +4269,13 @@ F_{l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \qquad \forall\, l \in \mathc ```yaml Link_p_nom_set: description: "`Link-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [link] + dims: [scenario, link] where: Link_p_nom_extendable AND Link_p_nom_set expression: Link_p_nom_ext == Link_p_nom_set ``` ```math -F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{nom,set}}_{l} \text{ is defined} +F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{nom,set}}_{\xi,l} \text{ is defined} ``` ### `Process-fix-p-lower` @@ -4146,7 +4291,7 @@ Process_fix_p_lower: ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( 1 - \gamma^{z}_{j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \gamma^{z}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-fix-p-upper` @@ -4162,7 +4307,7 @@ Process_fix_p_upper: ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( 1 - \gamma^{z}_{j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \gamma^{z}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-lower` @@ -4178,7 +4323,7 @@ Process_ext_p_lower: ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p-upper` @@ -4194,7 +4339,7 @@ Process_ext_p_upper: ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-ext-p_nom-lower` @@ -4203,14 +4348,14 @@ z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} ```yaml Process_ext_p_nom_lower: - description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [process] + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, process] where: Process_p_nom_extendable expression: Process_p_nom_ext >= Process_p_nom_min ``` ```math -Z_{j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} +Z_{j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} ``` ### `Process-ext-p_nom-upper` @@ -4219,14 +4364,14 @@ Z_{j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \math ```yaml Process_ext_p_nom_upper: - description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [process] + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, process] where: Process_p_nom_extendable AND Process_p_nom_max expression: Process_p_nom_ext <= Process_p_nom_max ``` ```math -Z_{j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \text{ is defined} +Z_{j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \text{ is defined} ``` ### `Process-p_nom_set` @@ -4236,13 +4381,13 @@ Z_{j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \qquad \forall\, j \in \mathc ```yaml Process_p_nom_set: description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [process] + dims: [scenario, process] where: Process_p_nom_extendable AND Process_p_nom_set expression: Process_p_nom_ext == Process_p_nom_set ``` ```math -Z_{j} = \mathrm{z}^{\mathrm{nom,set}}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{nom,set}}_{j} \text{ is defined} +Z_{j} = \mathrm{z}^{\mathrm{nom,set}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{nom,set}}_{\xi,j} \text{ is defined} ``` ### `StorageUnit-fix-p_dispatch-lower` @@ -4274,7 +4419,7 @@ StorageUnit_fix_p_dispatch_upper: ``` ```math -h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{\xi,t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-p_store-lower` @@ -4308,7 +4453,7 @@ StorageUnit_fix_p_store_upper: ``` ```math -h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{\xi,t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-fix-state_of_charge-lower` @@ -4340,7 +4485,7 @@ StorageUnit_fix_state_of_charge_upper: ``` ```math -\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{s} \cdot \mathrm{h}^{\mathrm{nom}}_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{\xi,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `Generator-com-p-lower` @@ -4356,7 +4501,7 @@ Generator_com_p_lower: ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-p-upper` @@ -4372,7 +4517,7 @@ Generator_com_p_upper: ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-transition-start-up` @@ -4423,7 +4568,7 @@ Generator_com_up_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}} \mathit{up}_{\xi,t',g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{UT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}} \mathit{up}_{\xi,t',g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{UT}_{\xi,g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-down-time` @@ -4442,7 +4587,7 @@ Generator_com_down_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}} \mathit{dn}_{\xi,t',g} \le 1 - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{DT}_{g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}} \mathit{dn}_{\xi,t',g} \le 1 - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{DT}_{\xi,g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-status-min_up_time_must_stay_up` @@ -4458,7 +4603,7 @@ Generator_com_status_must_stay_up: ``` ```math -u_{\xi,t,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{t,g} \wedge \mathrm{on}_{t,g} +u_{\xi,t,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{\xi,t,g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-status-min_down_time_must_stay_up` @@ -4476,7 +4621,7 @@ Generator_com_status_must_stay_down: ``` ```math -u_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rest}_{t,g} \wedge \mathrm{on}_{t,g} +u_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rest}_{\xi,t,g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-run-bigM` @@ -4502,7 +4647,7 @@ Generator_p_ramp_limit_up_run_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -4528,7 +4673,7 @@ Generator_p_ramp_limit_up_start_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -4554,7 +4699,7 @@ Generator_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -4580,7 +4725,7 @@ Generator_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot P_{g} + \mathrm{M}_{g} - \mathrm{M}_{g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -4614,7 +4759,7 @@ Generator_com_ext_p_upper_cap: ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-upper-bigM` @@ -4630,7 +4775,7 @@ Generator_com_ext_p_upper_big_m: ``` ```math -p_{\xi,t,g} \le \mathrm{M}_{g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-lower` @@ -4651,7 +4796,7 @@ Generator_com_ext_p_lower: ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \left( P_{g} - \gamma_{g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) + \mathrm{M}_{g} \cdot u_{\xi,t,g} - \mathrm{M}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) + \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} - \mathrm{M}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-ext-p-lower-nonneg` @@ -4690,7 +4835,7 @@ Generator_com_mod_p_lower: ``` ```math -p_{\xi,t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-com-mod-p-upper` @@ -4708,7 +4853,7 @@ Generator_com_mod_p_upper: ``` ```math -p_{\xi,t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-status-p-fixed-upper` @@ -4727,7 +4872,7 @@ Generator_status_p_fixed_upper: ``` ```math -u_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +u_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-start_up-p-fixed-upper` @@ -4746,7 +4891,7 @@ Generator_start_up_p_fixed_upper: ``` ```math -\mathit{up}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +\mathit{up}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-shut_down-p-fixed-upper` @@ -4765,7 +4910,7 @@ Generator_shut_down_p_fixed_upper: ``` ```math -\mathit{dn}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +\mathit{dn}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-status-p_nom-variable-upper` @@ -4829,7 +4974,7 @@ Generator_maint_event_count: ``` ```math -\sum_{t \in \mathcal{T}} \mu^{\mathrm{up}}_{\xi,t,g} = \mathrm{n}^{\mathrm{mnt}}_{g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +\sum_{t \in \mathcal{T}} \mu^{\mathrm{up}}_{\xi,t,g} = \mathrm{n}^{\mathrm{mnt}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} ``` ### `Generator-maint-window` @@ -4848,7 +4993,7 @@ Generator_maint_window: ``` ```math -\mu_{\xi,t,g} = \sum_{t' \in \mathcal{T} \,:\, \left( g,\ t',\ t \right) \in \mathrm{Generator\_maintenance\_cover}} \mu^{\mathrm{up}}_{\xi,t',g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +\mu_{\xi,t,g} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ g,\ t',\ t \right) \in \mathrm{Generator\_maintenance\_cover}} \mu^{\mathrm{up}}_{\xi,t',g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} ``` ### `Generator-maint-start-horizon` @@ -4864,7 +5009,7 @@ Generator_maint_start_horizon: ``` ```math -\mu^{\mathrm{up}}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} \wedge \mathrm{blk}_{t,g} +\mu^{\mathrm{up}}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} \wedge \mathrm{blk}_{\xi,t,g} ``` ### `Generator-maintcap_upper` @@ -4882,7 +5027,7 @@ Generator_maintcap_upper: ``` ```math -\mu^{\mathrm{nom}}_{\xi,t,g} \le P_{g} - \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +\mu^{\mathrm{nom}}_{\xi,t,g} \le P_{g} - \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-maintcap_upper_nommax` @@ -4898,7 +5043,7 @@ Generator_maintcap_upper_nommax: ``` ```math -\mu^{\mathrm{nom}}_{\xi,t,g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +\mu^{\mathrm{nom}}_{\xi,t,g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-maintcap_lower_nommax` @@ -4914,7 +5059,7 @@ Generator_maintcap_lower_nommax: ``` ```math -\mu^{\mathrm{nom}}_{\xi,t,g} \ge P_{g} - \overline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +\mu^{\mathrm{nom}}_{\xi,t,g} \ge P_{g} - \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-maintcap_lower_nommin` @@ -4930,7 +5075,7 @@ Generator_maintcap_lower_nommin: ``` ```math -\mu^{\mathrm{nom}}_{\xi,t,g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} \wedge \underline{\mathrm{p}}^{\mathrm{nom}}_{g} > 0 +\mu^{\mathrm{nom}}_{\xi,t,g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} \wedge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} > 0 ``` ### `Generator-maint-status-le-status` @@ -5012,7 +5157,7 @@ Generator_maint_modstatus_le_maint: ``` ```math -\mu^{u}_{\xi,t,g} \le \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +\mu^{u}_{\xi,t,g} \le \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Generator-maint-modstatus-lb` @@ -5028,7 +5173,7 @@ Generator_maint_modstatus_lb: ``` ```math -\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} - \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} - \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} ``` ### `Link-com-p-lower` @@ -5044,7 +5189,7 @@ Link_com_p_lower: ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-p-upper` @@ -5060,7 +5205,7 @@ Link_com_p_upper: ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-transition-start-up` @@ -5111,7 +5256,7 @@ Link_com_up_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{f}} \mathit{up}^{f}_{\xi,t',l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{UT}^{f}_{l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{f}} \mathit{up}^{f}_{\xi,t',l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{UT}^{f}_{\xi,l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-down-time` @@ -5130,7 +5275,7 @@ Link_com_down_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{f}} \mathit{dn}^{f}_{\xi,t',l} \le 1 - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{DT}^{f}_{l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{f}} \mathit{dn}^{f}_{\xi,t',l} \le 1 - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{DT}^{f}_{\xi,l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-status-min_up_time_must_stay_up` @@ -5146,7 +5291,7 @@ Link_com_status_must_stay_up: ``` ```math -u^{f}_{\xi,t,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{hold}^{f}_{t,l} \wedge \mathrm{on}^{f}_{t,l} +u^{f}_{\xi,t,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{hold}^{f}_{\xi,t,l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-status-min_down_time_must_stay_up` @@ -5164,7 +5309,7 @@ Link_com_status_must_stay_down: ``` ```math -u^{f}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{rest}^{f}_{t,l} \wedge \mathrm{on}^{f}_{t,l} +u^{f}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{rest}^{f}_{\xi,t,l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-run-bigM` @@ -5190,7 +5335,7 @@ Link_p_ramp_limit_up_run_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-start-bigM` @@ -5216,7 +5361,7 @@ Link_p_ramp_limit_up_start_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-run-bigM` @@ -5242,7 +5387,7 @@ Link_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-shut-bigM` @@ -5268,7 +5413,7 @@ Link_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot F_{l} + \mathrm{M}^{f}_{l} - \mathrm{M}^{f}_{l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p_nom_modularity` @@ -5302,7 +5447,7 @@ Link_com_ext_p_upper_cap: ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-ext-p-upper-bigM` @@ -5318,7 +5463,7 @@ Link_com_ext_p_upper_big_m: ``` ```math -f_{\xi,t,l} \le \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-ext-p-lower` @@ -5339,7 +5484,7 @@ Link_com_ext_p_lower: ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \left( F_{l} - \gamma^{f}_{l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) + \mathrm{M}^{f}_{l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) + \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-ext-p-lower-nonneg` @@ -5378,7 +5523,7 @@ Link_com_mod_p_lower: ``` ```math -f_{\xi,t,l} \ge \underline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-com-mod-p-upper` @@ -5396,7 +5541,7 @@ Link_com_mod_p_upper: ``` ```math -f_{\xi,t,l} \le \overline{\mathrm{f}}_{t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-status-p-fixed-upper` @@ -5415,7 +5560,7 @@ Link_status_p_fixed_upper: ``` ```math -u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-start_up-p-fixed-upper` @@ -5434,7 +5579,7 @@ Link_start_up_p_fixed_upper: ``` ```math -\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-shut_down-p-fixed-upper` @@ -5453,7 +5598,7 @@ Link_shut_down_p_fixed_upper: ``` ```math -\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-status-p_nom-variable-upper` @@ -5517,7 +5662,7 @@ Link_maint_event_count: ``` ```math -\sum_{t \in \mathcal{T}} \mu^{f,\mathrm{up}}_{\xi,t,l} = \mathrm{n}^{f,\mathrm{mnt}}_{l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +\sum_{t \in \mathcal{T}} \mu^{f,\mathrm{up}}_{\xi,t,l} = \mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} ``` ### `Link-maint-window` @@ -5536,7 +5681,7 @@ Link_maint_window: ``` ```math -\mu^{f}_{\xi,t,l} = \sum_{t' \in \mathcal{T} \,:\, \left( l,\ t',\ t \right) \in \mathrm{Link\_maintenance\_cover}} \mu^{f,\mathrm{up}}_{\xi,t',l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +\mu^{f}_{\xi,t,l} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ l,\ t',\ t \right) \in \mathrm{Link\_maintenance\_cover}} \mu^{f,\mathrm{up}}_{\xi,t',l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-maint-start-horizon` @@ -5552,7 +5697,7 @@ Link_maint_start_horizon: ``` ```math -\mu^{f,\mathrm{up}}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} \wedge \mathrm{blk}^{f}_{t,l} +\mu^{f,\mathrm{up}}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} \wedge \mathrm{blk}^{f}_{\xi,t,l} ``` ### `Link-maintcap_upper` @@ -5570,7 +5715,7 @@ Link_maintcap_upper: ``` ```math -\mu^{f,\mathrm{nom}}_{\xi,t,l} \le F_{l} - \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le F_{l} - \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-maintcap_upper_nommax` @@ -5586,7 +5731,7 @@ Link_maintcap_upper_nommax: ``` ```math -\mu^{f,\mathrm{nom}}_{\xi,t,l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-maintcap_lower_nommax` @@ -5602,7 +5747,7 @@ Link_maintcap_lower_nommax: ``` ```math -\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge F_{l} - \overline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge F_{l} - \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-maintcap_lower_nommin` @@ -5618,7 +5763,7 @@ Link_maintcap_lower_nommin: ``` ```math -\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} \wedge \underline{\mathrm{f}}^{\mathrm{nom}}_{l} > 0 +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} \wedge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} > 0 ``` ### `Link-maint-status-le-status` @@ -5700,7 +5845,7 @@ Link_maint_modstatus_le_maint: ``` ```math -\mu^{f,u}_{\xi,t,l} \le \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \le \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-maint-modstatus-lb` @@ -5716,7 +5861,7 @@ Link_maint_modstatus_lb: ``` ```math -\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-com-p-lower` @@ -5732,7 +5877,7 @@ Process_com_p_lower: ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-p-upper` @@ -5748,7 +5893,7 @@ Process_com_p_upper: ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-transition-start-up` @@ -5799,7 +5944,7 @@ Process_com_up_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{z}} \mathit{up}^{z}_{\xi,t',j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{UT}^{z}_{j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{z}} \mathit{up}^{z}_{\xi,t',j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{UT}^{z}_{\xi,j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-down-time` @@ -5818,7 +5963,7 @@ Process_com_down_time: ``` ```math -\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{z}} \mathit{dn}^{z}_{\xi,t',j} \le 1 - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{DT}^{z}_{j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{z}} \mathit{dn}^{z}_{\xi,t',j} \le 1 - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{DT}^{z}_{\xi,j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-status-min_up_time_must_stay_up` @@ -5834,7 +5979,7 @@ Process_com_status_must_stay_up: ``` ```math -u^{z}_{\xi,t,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{hold}^{z}_{t,j} \wedge \mathrm{on}^{z}_{t,j} +u^{z}_{\xi,t,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{hold}^{z}_{\xi,t,j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-status-min_down_time_must_stay_up` @@ -5852,7 +5997,7 @@ Process_com_status_must_stay_down: ``` ```math -u^{z}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{rest}^{z}_{t,j} \wedge \mathrm{on}^{z}_{t,j} +u^{z}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{rest}^{z}_{\xi,t,j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-run-bigM` @@ -5878,7 +6023,7 @@ Process_p_ramp_limit_up_run_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-start-bigM` @@ -5904,7 +6049,7 @@ Process_p_ramp_limit_up_start_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-run-bigM` @@ -5930,7 +6075,7 @@ Process_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-shut-bigM` @@ -5956,7 +6101,7 @@ Process_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot Z_{j} + \mathrm{M}^{z}_{j} - \mathrm{M}^{z}_{j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p_nom_modularity` @@ -5990,7 +6135,7 @@ Process_com_ext_p_upper_cap: ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-ext-p-upper-bigM` @@ -6006,7 +6151,7 @@ Process_com_ext_p_upper_big_m: ``` ```math -z_{\xi,t,j} \le \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-ext-p-lower` @@ -6027,7 +6172,7 @@ Process_com_ext_p_lower: ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \left( Z_{j} - \gamma^{z}_{j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) + \mathrm{M}^{z}_{j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) + \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-ext-p-lower-nonneg` @@ -6066,7 +6211,7 @@ Process_com_mod_p_lower: ``` ```math -z_{\xi,t,j} \ge \underline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-com-mod-p-upper` @@ -6084,7 +6229,7 @@ Process_com_mod_p_upper: ``` ```math -z_{\xi,t,j} \le \overline{\mathrm{z}}_{t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-status-p-fixed-upper` @@ -6103,7 +6248,7 @@ Process_status_p_fixed_upper: ``` ```math -u^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +u^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-start_up-p-fixed-upper` @@ -6122,7 +6267,7 @@ Process_start_up_p_fixed_upper: ``` ```math -\mathit{up}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\mathit{up}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-shut_down-p-fixed-upper` @@ -6141,7 +6286,7 @@ Process_shut_down_p_fixed_upper: ``` ```math -\mathit{dn}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\mathit{dn}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-status-p_nom-variable-upper` @@ -6205,7 +6350,7 @@ Process_maint_event_count: ``` ```math -\sum_{t \in \mathcal{T}} \mu^{z,\mathrm{up}}_{\xi,t,j} = \mathrm{n}^{z,\mathrm{mnt}}_{j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +\sum_{t \in \mathcal{T}} \mu^{z,\mathrm{up}}_{\xi,t,j} = \mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} ``` ### `Process-maint-window` @@ -6224,7 +6369,7 @@ Process_maint_window: ``` ```math -\mu^{z}_{\xi,t,j} = \sum_{t' \in \mathcal{T} \,:\, \left( j,\ t',\ t \right) \in \mathrm{Process\_maintenance\_cover}} \mu^{z,\mathrm{up}}_{\xi,t',j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +\mu^{z}_{\xi,t,j} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ j,\ t',\ t \right) \in \mathrm{Process\_maintenance\_cover}} \mu^{z,\mathrm{up}}_{\xi,t',j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-maint-start-horizon` @@ -6240,7 +6385,7 @@ Process_maint_start_horizon: ``` ```math -\mu^{z,\mathrm{up}}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} \wedge \mathrm{blk}^{z}_{t,j} +\mu^{z,\mathrm{up}}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} \wedge \mathrm{blk}^{z}_{\xi,t,j} ``` ### `Process-maintcap_upper` @@ -6258,7 +6403,7 @@ Process_maintcap_upper: ``` ```math -\mu^{z,\mathrm{nom}}_{\xi,t,j} \le Z_{j} - \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le Z_{j} - \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-maintcap_upper_nommax` @@ -6274,7 +6419,7 @@ Process_maintcap_upper_nommax: ``` ```math -\mu^{z,\mathrm{nom}}_{\xi,t,j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-maintcap_lower_nommax` @@ -6290,7 +6435,7 @@ Process_maintcap_lower_nommax: ``` ```math -\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge Z_{j} - \overline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge Z_{j} - \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-maintcap_lower_nommin` @@ -6306,7 +6451,7 @@ Process_maintcap_lower_nommin: ``` ```math -\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} \wedge \underline{\mathrm{z}}^{\mathrm{nom}}_{j} > 0 +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} \wedge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} > 0 ``` ### `Process-maint-status-le-status` @@ -6388,7 +6533,7 @@ Process_maint_modstatus_le_maint: ``` ```math -\mu^{z,u}_{\xi,t,j} \le \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +\mu^{z,u}_{\xi,t,j} \le \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-maint-modstatus-lb` @@ -6404,7 +6549,7 @@ Process_maint_modstatus_lb: ``` ```math -\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} ``` ### `Line-fix-s-lower` @@ -6420,7 +6565,7 @@ Line_fix_s_lower: ``` ```math -s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-fix-s-upper` @@ -6436,7 +6581,7 @@ Line_fix_s_upper: ``` ```math -s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-lower` @@ -6452,7 +6597,7 @@ Line_ext_s_lower: ``` ```math -s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s-upper` @@ -6468,7 +6613,7 @@ Line_ext_s_upper: ``` ```math -s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-ext-s_nom-lower` @@ -6477,14 +6622,14 @@ s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad ```yaml Line_ext_s_nom_lower: - description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [line] + description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, line] where: Line_s_nom_extendable expression: Line_s_nom_ext >= Line_s_nom_min ``` ```math -S_{k} \ge \underline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} +S_{k} \ge \underline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} ``` ### `Line-ext-s_nom-upper` @@ -6493,14 +6638,14 @@ S_{k} \ge \underline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \math ```yaml Line_ext_s_nom_upper: - description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [line] + description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, line] where: Line_s_nom_extendable AND Line_s_nom_max expression: Line_s_nom_ext <= Line_s_nom_max ``` ```math -S_{k} \le \overline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \overline{\mathrm{s}}^{\mathrm{nom}}_{k} \text{ is defined} +S_{k} \le \overline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \overline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \text{ is defined} ``` ### `Line-s_nom_set` @@ -6510,13 +6655,13 @@ S_{k} \le \overline{\mathrm{s}}^{\mathrm{nom}}_{k} \qquad \forall\, k \in \mathc ```yaml Line_s_nom_set: description: "`Line-s_nom_set` — the chosen build pinned, wherever a value is given" - dims: [line] + dims: [scenario, line] where: Line_s_nom_extendable AND Line_s_nom_set expression: Line_s_nom_ext == Line_s_nom_set ``` ```math -S_{k} = \mathrm{s}^{\mathrm{nom,set}}_{k} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{s}^{\mathrm{nom,set}}_{k} \text{ is defined} +S_{k} = \mathrm{s}^{\mathrm{nom,set}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{s}^{\mathrm{nom,set}}_{\xi,k} \text{ is defined} ``` ### `Line-s_set` @@ -6532,7 +6677,7 @@ Line_s_set: ``` ```math -s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} +s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{\xi,t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{\xi,t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-loss_upper` @@ -6548,7 +6693,7 @@ Line_loss_upper: ``` ```math -\ell_{\xi,t,k} \le \overline{\ell}_{t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +\ell_{\xi,t,k} \le \overline{\ell}_{\xi,t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-loss_tangents-{k}-1` @@ -6568,7 +6713,7 @@ Line_loss_tangents_forward: ``` ```math -\ell_{\xi,t,k} + \mathrm{a}_{t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +\ell_{\xi,t,k} + \mathrm{a}_{\xi,t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{\xi,t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Line-loss_tangents-{k}--1` @@ -6586,7 +6731,7 @@ Line_loss_tangents_reverse: ``` ```math -\ell_{\xi,t,k} - \mathrm{a}_{t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +\ell_{\xi,t,k} - \mathrm{a}_{\xi,t,k,b} \cdot s_{\xi,t,k} \ge \mathrm{b}_{\xi,t,k,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} ``` ### `Transformer-fix-s-lower` @@ -6602,7 +6747,7 @@ Transformer_fix_s_lower: ``` ```math -\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-fix-s-upper` @@ -6618,7 +6763,7 @@ Transformer_fix_s_upper: ``` ```math -\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s-lower` @@ -6634,7 +6779,7 @@ Transformer_ext_s_lower: ``` ```math -\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s-upper` @@ -6650,7 +6795,7 @@ Transformer_ext_s_upper: ``` ```math -\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-ext-s_nom-lower` @@ -6659,14 +6804,14 @@ Transformer_ext_s_upper: ```yaml Transformer_ext_s_nom_lower: - description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [transformer] + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, transformer] where: Transformer_s_nom_extendable expression: Transformer_s_nom_ext >= Transformer_s_nom_min ``` ```math -\Sigma_{m} \ge \underline{\sigma}^{\mathrm{nom}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} +\Sigma_{m} \ge \underline{\sigma}^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} ``` ### `Transformer-ext-s_nom-upper` @@ -6675,14 +6820,14 @@ Transformer_ext_s_nom_lower: ```yaml Transformer_ext_s_nom_upper: - description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [transformer] + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, transformer] where: Transformer_s_nom_extendable AND Transformer_s_nom_max expression: Transformer_s_nom_ext <= Transformer_s_nom_max ``` ```math -\Sigma_{m} \le \overline{\sigma}^{\mathrm{nom}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \overline{\sigma}^{\mathrm{nom}}_{m} \text{ is defined} +\Sigma_{m} \le \overline{\sigma}^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \overline{\sigma}^{\mathrm{nom}}_{\xi,m} \text{ is defined} ``` ### `Transformer-s_nom_set` @@ -6692,13 +6837,13 @@ Transformer_ext_s_nom_upper: ```yaml Transformer_s_nom_set: description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" - dims: [transformer] + dims: [scenario, transformer] where: Transformer_s_nom_extendable AND Transformer_s_nom_set expression: Transformer_s_nom_ext == Transformer_s_nom_set ``` ```math -\Sigma_{m} = \sigma^{\mathrm{nom,set}}_{m} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \sigma^{\mathrm{nom,set}}_{m} \text{ is defined} +\Sigma_{m} = \sigma^{\mathrm{nom,set}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \sigma^{\mathrm{nom,set}}_{\xi,m} \text{ is defined} ``` ### `Transformer-s_set` @@ -6714,7 +6859,7 @@ Transformer_s_set: ``` ```math -\sigma_{\xi,t,m} = \sigma^{\mathrm{set}}_{t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \sigma^{\mathrm{set}}_{t,m} \text{ is defined} \wedge \mathrm{on}^{\sigma}_{t,m} +\sigma_{\xi,t,m} = \sigma^{\mathrm{set}}_{\xi,t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \sigma^{\mathrm{set}}_{\xi,t,m} \text{ is defined} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-loss_upper` @@ -6730,7 +6875,7 @@ Transformer_loss_upper: ``` ```math -\ell^{\sigma}_{\xi,t,m} \le \overline{\ell}^{\sigma}_{t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +\ell^{\sigma}_{\xi,t,m} \le \overline{\ell}^{\sigma}_{\xi,t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-loss_tangents-{k}-1` @@ -6749,7 +6894,7 @@ Transformer_loss_tangents_forward: ``` ```math -\ell^{\sigma}_{\xi,t,m} + \mathrm{a}^{\sigma}_{t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +\ell^{\sigma}_{\xi,t,m} + \mathrm{a}^{\sigma}_{\xi,t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{\xi,t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Transformer-loss_tangents-{k}--1` @@ -6767,7 +6912,7 @@ Transformer_loss_tangents_reverse: ``` ```math -\ell^{\sigma}_{\xi,t,m} - \mathrm{a}^{\sigma}_{t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +\ell^{\sigma}_{\xi,t,m} - \mathrm{a}^{\sigma}_{\xi,t,m,b} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{\xi,t,m,b} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ b \in \mathcal{B} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} ``` ### `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -6789,7 +6934,7 @@ Line_fix_s_lower_security: ``` ```math -\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -6810,7 +6955,7 @@ Line_fix_s_upper_security: ``` ```math -\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -6832,7 +6977,7 @@ Line_ext_s_lower_security: ``` ```math -\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -6854,7 +6999,7 @@ Line_ext_s_upper_security: ``` ```math -\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -6876,7 +7021,7 @@ Transformer_fix_s_lower_security: ``` ```math -\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -6897,7 +7042,7 @@ Transformer_fix_s_upper_security: ``` ```math -\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{t,m} \cdot \sigma^{\mathrm{nom}}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -6919,7 +7064,7 @@ Transformer_ext_s_lower_security: ``` ```math -\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -6941,7 +7086,7 @@ Transformer_ext_s_upper_security: ``` ```math -\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Kirchhoff-Voltage-Law` @@ -6991,7 +7136,7 @@ Generator_p_ramp_limit_up: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -7016,7 +7161,7 @@ Generator_p_ramp_limit_down: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -7041,7 +7186,7 @@ Link_p_ramp_limit_up: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -7066,7 +7211,7 @@ Link_p_ramp_limit_down: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -7091,7 +7236,7 @@ Process_p_ramp_limit_up: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -7116,7 +7261,7 @@ Process_p_ramp_limit_down: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -7148,7 +7293,7 @@ StorageUnit_ext_p_dispatch_upper: ``` ```math -h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{\xi,t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_store-lower` @@ -7182,7 +7327,7 @@ StorageUnit_ext_p_store_upper: ``` ```math -h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{\xi,t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-state_of_charge-lower` @@ -7214,7 +7359,7 @@ StorageUnit_ext_state_of_charge_upper: ``` ```math -\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{\xi,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-ext-p_nom-lower` @@ -7223,14 +7368,14 @@ StorageUnit_ext_state_of_charge_upper: ```yaml StorageUnit_ext_p_nom_lower: - description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [storage_unit] + description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable expression: StorageUnit_p_nom_ext >= StorageUnit_p_nom_min ``` ```math -H_{s} \ge \underline{\mathrm{h}}^{\mathrm{nom}}_{s} \qquad \forall\, s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +H_{s} \ge \underline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} ``` ### `StorageUnit-ext-p_nom-upper` @@ -7239,14 +7384,14 @@ H_{s} \ge \underline{\mathrm{h}}^{\mathrm{nom}}_{s} \qquad \forall\, s \in \math ```yaml StorageUnit_ext_p_nom_upper: - description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [storage_unit] + description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_max expression: StorageUnit_p_nom_ext <= StorageUnit_p_nom_max ``` ```math -H_{s} \le \overline{\mathrm{h}}^{\mathrm{nom}}_{s} \qquad \forall\, s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \overline{\mathrm{h}}^{\mathrm{nom}}_{s} \text{ is defined} +H_{s} \le \overline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \overline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \text{ is defined} ``` ### `StorageUnit-p_nom_set` @@ -7256,13 +7401,13 @@ H_{s} \le \overline{\mathrm{h}}^{\mathrm{nom}}_{s} \qquad \forall\, s \in \mathc ```yaml StorageUnit_p_nom_set: description: "`StorageUnit-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [storage_unit] + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_set expression: StorageUnit_p_nom_ext == StorageUnit_p_nom_set ``` ```math -H_{s} = \mathrm{h}^{\mathrm{nom,set}}_{s} \qquad \forall\, s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{h}^{\mathrm{nom,set}}_{s} \text{ is defined} +H_{s} = \mathrm{h}^{\mathrm{nom,set}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{h}^{\mathrm{nom,set}}_{\xi,s} \text{ is defined} ``` ### `StorageUnit-energy_balance` @@ -7286,7 +7431,7 @@ StorageUnit_energy_balance: ``` ```math -\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{s}} + \left( \mathrm{inflow}_{t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{\xi,s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{\xi,s}} + \left( \mathrm{inflow}_{\xi,t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` ### `Store-fix-e-lower` @@ -7302,7 +7447,7 @@ Store_fix_e_lower: ``` ```math -e_{\xi,t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{\xi,t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-fix-e-upper` @@ -7318,7 +7463,7 @@ Store_fix_e_upper: ``` ```math -e_{\xi,t,v} \le \overline{\mathrm{e}}_{t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +e_{\xi,t,v} \le \overline{\mathrm{e}}_{\xi,t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e-lower` @@ -7334,7 +7479,7 @@ Store_ext_e_lower: ``` ```math -e_{\xi,t,v} \ge \underline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{\xi,t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e-upper` @@ -7350,7 +7495,7 @@ Store_ext_e_upper: ``` ```math -e_{\xi,t,v} \le \overline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +e_{\xi,t,v} \le \overline{\mathrm{e}}_{\xi,t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-ext-e_nom-lower` @@ -7359,14 +7504,14 @@ e_{\xi,t,v} \le \overline{\mathrm{e}}_{t,v} \cdot E_{v} \qquad \forall\, \xi \in ```yaml Store_ext_e_nom_lower: - description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor" - dims: [store] + description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, store] where: Store_e_nom_extendable expression: Store_e_nom_ext >= Store_e_nom_min ``` ```math -E_{v} \ge \underline{\mathrm{e}}^{\mathrm{nom}}_{v} \qquad \forall\, v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} +E_{v} \ge \underline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} ``` ### `Store-ext-e_nom-upper` @@ -7375,14 +7520,14 @@ E_{v} \ge \underline{\mathrm{e}}^{\mathrm{nom}}_{v} \qquad \forall\, v \in \math ```yaml Store_ext_e_nom_upper: - description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [store] + description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, store] where: Store_e_nom_extendable AND Store_e_nom_max expression: Store_e_nom_ext <= Store_e_nom_max ``` ```math -E_{v} \le \overline{\mathrm{e}}^{\mathrm{nom}}_{v} \qquad \forall\, v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \overline{\mathrm{e}}^{\mathrm{nom}}_{v} \text{ is defined} +E_{v} \le \overline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \overline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \text{ is defined} ``` ### `Store-e_nom_set` @@ -7392,13 +7537,13 @@ E_{v} \le \overline{\mathrm{e}}^{\mathrm{nom}}_{v} \qquad \forall\, v \in \mathc ```yaml Store_e_nom_set: description: "`Store-e_nom_set` — the chosen build pinned, wherever a value is given" - dims: [store] + dims: [scenario, store] where: Store_e_nom_extendable AND Store_e_nom_set expression: Store_e_nom_ext == Store_e_nom_set ``` ```math -E_{v} = \mathrm{e}^{\mathrm{nom,set}}_{v} \qquad \forall\, v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{e}^{\mathrm{nom,set}}_{v} \text{ is defined} +E_{v} = \mathrm{e}^{\mathrm{nom,set}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{e}^{\mathrm{nom,set}}_{\xi,v} \text{ is defined} ``` ### `Store-energy_balance` @@ -7433,7 +7578,7 @@ Generator_p_set: ``` ```math -p_{\xi,t,g} = \mathrm{p}^{\mathrm{set}}_{t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{set}}_{t,g} \text{ is defined} \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} = \mathrm{p}^{\mathrm{set}}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{set}}_{\xi,t,g} \text{ is defined} \wedge \mathrm{on}_{t,g} ``` ### `Link-p_set` @@ -7449,7 +7594,7 @@ Link_p_set: ``` ```math -f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{t,l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{\xi,t,l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p_set` @@ -7465,7 +7610,7 @@ Process_p_set: ``` ```math -z_{\xi,t,j} = \mathrm{z}^{\mathrm{set}}_{t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{z}^{\mathrm{set}}_{t,j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} = \mathrm{z}^{\mathrm{set}}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{z}^{\mathrm{set}}_{\xi,t,j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-p_set` @@ -7481,7 +7626,7 @@ StorageUnit_p_set: ``` ```math -h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} = \mathrm{h}^{\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} = \mathrm{h}^{\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-p_dispatch_set` @@ -7497,7 +7642,7 @@ StorageUnit_p_dispatch_set: ``` ```math -h^{+}_{\xi,t,s} = \mathrm{h}^{+,\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{+,\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +h^{+}_{\xi,t,s} = \mathrm{h}^{+,\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{+,\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-p_store_set` @@ -7513,7 +7658,7 @@ StorageUnit_p_store_set: ``` ```math -h^{-}_{\xi,t,s} = \mathrm{h}^{-,\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{-,\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +h^{-}_{\xi,t,s} = \mathrm{h}^{-,\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{-,\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `StorageUnit-state_of_charge_set` @@ -7529,7 +7674,7 @@ StorageUnit_state_of_charge_set: ``` ```math -\mathit{soc}_{\xi,t,s} = \mathrm{soc}^{\mathrm{set}}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{soc}^{\mathrm{set}}_{t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +\mathit{soc}_{\xi,t,s} = \mathrm{soc}^{\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{soc}^{\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} ``` ### `Store-e_set` @@ -7545,7 +7690,7 @@ Store_e_set: ``` ```math -e_{\xi,t,v} = \mathrm{e}^{\mathrm{set}}_{t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} +e_{\xi,t,v} = \mathrm{e}^{\mathrm{set}}_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{\xi,t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} ``` ### `Store-p_set` @@ -7561,7 +7706,7 @@ Store_p_set: ``` ```math -q_{\xi,t,v} = \mathrm{q}^{\mathrm{set}}_{t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{q}^{\mathrm{set}}_{t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} +q_{\xi,t,v} = \mathrm{q}^{\mathrm{set}}_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{q}^{\mathrm{set}}_{\xi,t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} ``` ### `primary_energy` @@ -7673,7 +7818,7 @@ GlobalConstraint_transmission_volume_expansion_limit_ub: ``` ```math -\mathit{transmission\_volume\_expansion}_{i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_volume\_expansion}_{\xi,i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -7689,7 +7834,7 @@ GlobalConstraint_transmission_volume_expansion_limit_lb: ``` ```math -\mathit{transmission\_volume\_expansion}_{i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_volume\_expansion}_{\xi,i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_volume_expansion_limit` @@ -7705,7 +7850,7 @@ GlobalConstraint_transmission_volume_expansion_limit_eq: ``` ```math -\mathit{transmission\_volume\_expansion}_{i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_volume\_expansion}_{\xi,i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7721,7 +7866,7 @@ GlobalConstraint_transmission_expansion_cost_limit_ub: ``` ```math -\mathit{transmission\_expansion\_cost}_{i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} +\mathit{transmission\_expansion\_cost}_{\xi,i} \le \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{<=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7737,7 +7882,7 @@ GlobalConstraint_transmission_expansion_cost_limit_lb: ``` ```math -\mathit{transmission\_expansion\_cost}_{i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} +\mathit{transmission\_expansion\_cost}_{\xi,i} \ge \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{>=}\text{'} ``` ### `transmission_expansion_cost_limit` @@ -7753,7 +7898,7 @@ GlobalConstraint_transmission_expansion_cost_limit_eq: ``` ```math -\mathit{transmission\_expansion\_cost}_{i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} +\mathit{transmission\_expansion\_cost}_{\xi,i} = \mathrm{K}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} \,:\, \mathrm{type}_{i} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,i} = \text{'}\mathrm{==}\text{'} ``` ### `tech_capacity_expansion_limit` @@ -7912,7 +8057,7 @@ Generator_previous_status: ``` ```math -\overleftarrow{u}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ u_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\overleftarrow{u}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{\xi,g} & \text{if } \mathrm{pos}(t) = 0 \\ u_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_previous_p` @@ -7938,14 +8083,14 @@ Generator_previous_p: ```yaml Generator_p_nom_effective: description: the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [generator] + dims: [scenario, generator] cases: extendable: { when: Generator_p_nom_extendable, expression: Generator_p_nom_ext } otherwise: Generator_p_nom ``` ```math -\widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} = \begin{cases} P_{g} & \text{if } \mathrm{ext}_{g} \\ \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} = \begin{cases} P_{g} & \text{if } \mathrm{ext}_{g} \\ \mathrm{p}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_ramp_up_rate` @@ -7955,14 +8100,14 @@ Generator_ramp_up_rate: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}_{g} = \begin{cases} \mathrm{ru}_{g} & \text{if } \mathrm{ru}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{ru}}_{\xi,g} = \begin{cases} \mathrm{ru}_{\xi,g} & \text{if } \mathrm{ru}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_ramp_down_rate` @@ -7972,14 +8117,14 @@ Generator_ramp_down_rate: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}_{g} = \begin{cases} \mathrm{rd}_{g} & \text{if } \mathrm{rd}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{rd}}_{\xi,g} = \begin{cases} \mathrm{rd}_{\xi,g} & \text{if } \mathrm{rd}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_start_up_rate` @@ -7989,14 +8134,14 @@ Generator_start_up_rate: description: >- the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} = \begin{cases} \mathrm{ru}^{\mathrm{up}}_{g} & \text{if } \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} = \begin{cases} \mathrm{ru}^{\mathrm{up}}_{\xi,g} & \text{if } \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_shut_down_rate` @@ -8006,14 +8151,14 @@ Generator_shut_down_rate: description: >- the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{\xi,g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_p_nom_committed` @@ -8023,14 +8168,14 @@ Generator_p_nom_committed: description: >- the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [generator] + dims: [scenario, generator] cases: modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } otherwise: Generator_p_nom ``` ```math -\widehat{\mathrm{p}}^{\mathrm{nom}}_{g} = \begin{cases} \mathrm{p}^{\mathrm{mod}}_{g} & \text{if } \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \\ \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} = \begin{cases} \mathrm{p}^{\mathrm{mod}}_{g} & \text{if } \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \\ \mathrm{p}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} ``` ### `Generator_ramp_up_allowance` @@ -8053,7 +8198,7 @@ Generator_ramp_up_allowance: ``` ```math -\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{\xi,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_allowance` @@ -8076,7 +8221,7 @@ Generator_ramp_down_allowance: ``` ```math -\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{\xi,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Link_p_nom_effective` @@ -8084,14 +8229,14 @@ Generator_ramp_down_allowance: ```yaml Link_p_nom_effective: description: the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [link] + dims: [scenario, link] cases: extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } otherwise: Link_p_nom ``` ```math -\widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} = \begin{cases} F_{l} & \text{if } \mathrm{ext}^{f}_{l} \\ \mathrm{f}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} = \begin{cases} F_{l} & \text{if } \mathrm{ext}^{f}_{l} \\ \mathrm{f}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_previous_status` @@ -8108,7 +8253,7 @@ Link_previous_status: ``` ```math -\overleftarrow{u}^{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{l} & \text{if } \mathrm{pos}(t) = 0 \\ u^{f}_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\overleftarrow{u}^{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{\xi,l} & \text{if } \mathrm{pos}(t) = 0 \\ u^{f}_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_previous_p` @@ -8136,14 +8281,14 @@ Link_ramp_up_rate: description: >- the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{f}_{l} = \begin{cases} \mathrm{ru}^{f}_{l} & \text{if } \mathrm{ru}^{f}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widetilde{\mathrm{ru}}^{f}_{\xi,l} = \begin{cases} \mathrm{ru}^{f}_{\xi,l} & \text{if } \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_ramp_down_rate` @@ -8153,14 +8298,14 @@ Link_ramp_down_rate: description: >- the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{f}_{l} = \begin{cases} \mathrm{rd}^{f}_{l} & \text{if } \mathrm{rd}^{f}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widetilde{\mathrm{rd}}^{f}_{\xi,l} = \begin{cases} \mathrm{rd}^{f}_{\xi,l} & \text{if } \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_start_up_rate` @@ -8170,14 +8315,14 @@ Link_start_up_rate: description: >- the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} = \begin{cases} \mathrm{ru}^{f,\mathrm{up}}_{l} & \text{if } \mathrm{ru}^{f,\mathrm{up}}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} = \begin{cases} \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} & \text{if } \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_shut_down_rate` @@ -8187,14 +8332,14 @@ Link_shut_down_rate: description: >- the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} = \begin{cases} \mathrm{rd}^{f,\mathrm{dn}}_{l} & \text{if } \mathrm{rd}^{f,\mathrm{dn}}_{l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} = \begin{cases} \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} & \text{if } \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_p_nom_committed` @@ -8204,14 +8349,14 @@ Link_p_nom_committed: description: >- the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [link] + dims: [scenario, link] cases: modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } otherwise: Link_p_nom ``` ```math -\widehat{\mathrm{f}}^{\mathrm{nom}}_{l} = \begin{cases} \mathrm{f}^{\mathrm{mod}}_{l} & \text{if } \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \\ \mathrm{f}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, l \in \mathcal{L} +\widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} = \begin{cases} \mathrm{f}^{\mathrm{mod}}_{l} & \text{if } \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \\ \mathrm{f}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} ``` ### `Link_ramp_up_allowance` @@ -8234,7 +8379,7 @@ Link_ramp_up_allowance: ``` ```math -\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_ramp_down_allowance` @@ -8257,7 +8402,7 @@ Link_ramp_down_allowance: ``` ```math -\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Process_p_nom_effective` @@ -8265,14 +8410,14 @@ Link_ramp_down_allowance: ```yaml Process_p_nom_effective: description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [process] + dims: [scenario, process] cases: extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } otherwise: Process_p_nom ``` ```math -\widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} = \begin{cases} Z_{j} & \text{if } \mathrm{ext}^{z}_{j} \\ \mathrm{z}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} = \begin{cases} Z_{j} & \text{if } \mathrm{ext}^{z}_{j} \\ \mathrm{z}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_previous_status` @@ -8289,7 +8434,7 @@ Process_previous_status: ``` ```math -\overleftarrow{u}^{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{j} & \text{if } \mathrm{pos}(t) = 0 \\ u^{z}_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\overleftarrow{u}^{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{\xi,j} & \text{if } \mathrm{pos}(t) = 0 \\ u^{z}_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_previous_p` @@ -8317,14 +8462,14 @@ Process_ramp_up_rate: description: >- the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{z}_{j} = \begin{cases} \mathrm{ru}^{z}_{j} & \text{if } \mathrm{ru}^{z}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widetilde{\mathrm{ru}}^{z}_{\xi,j} = \begin{cases} \mathrm{ru}^{z}_{\xi,j} & \text{if } \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_ramp_down_rate` @@ -8334,14 +8479,14 @@ Process_ramp_down_rate: description: >- the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{z}_{j} = \begin{cases} \mathrm{rd}^{z}_{j} & \text{if } \mathrm{rd}^{z}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widetilde{\mathrm{rd}}^{z}_{\xi,j} = \begin{cases} \mathrm{rd}^{z}_{\xi,j} & \text{if } \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_start_up_rate` @@ -8351,14 +8496,14 @@ Process_start_up_rate: description: >- the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} = \begin{cases} \mathrm{ru}^{z,\mathrm{up}}_{j} & \text{if } \mathrm{ru}^{z,\mathrm{up}}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} = \begin{cases} \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} & \text{if } \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_shut_down_rate` @@ -8368,14 +8513,14 @@ Process_shut_down_rate: description: >- the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} = \begin{cases} \mathrm{rd}^{z,\mathrm{dn}}_{j} & \text{if } \mathrm{rd}^{z,\mathrm{dn}}_{j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} = \begin{cases} \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} & \text{if } \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_p_nom_committed` @@ -8385,14 +8530,14 @@ Process_p_nom_committed: description: >- the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [process] + dims: [scenario, process] cases: modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } otherwise: Process_p_nom ``` ```math -\widehat{\mathrm{z}}^{\mathrm{nom}}_{j} = \begin{cases} \mathrm{z}^{\mathrm{mod}}_{j} & \text{if } \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \\ \mathrm{z}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, j \in \mathcal{J} +\widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} = \begin{cases} \mathrm{z}^{\mathrm{mod}}_{j} & \text{if } \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \\ \mathrm{z}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} ``` ### `Process_ramp_up_allowance` @@ -8415,7 +8560,7 @@ Process_ramp_up_allowance: ``` ```math -\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_ramp_down_allowance` @@ -8438,7 +8583,7 @@ Process_ramp_down_allowance: ``` ```math -\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `StorageUnit_charge_carried_in` @@ -8483,7 +8628,7 @@ StorageUnit_charge_carried_in: ``` ```math -\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{t,s} \cdot \mathit{soc}_{\xi,\left( t \ominus \mathrm{idle} \right) \ominus 1,s} & \text{if } \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \mathrm{soc}^{0}_{s} & \text{if } \neg \mathrm{cyc}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \neg \mathrm{reset}_{s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{s} \\ \mathrm{soc}^{0}_{s} & \text{if } \mathrm{reset}_{s} \wedge \neg \mathrm{cyc}^{y}_{s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} +\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,\left( t \ominus \mathrm{idle} \right) \ominus 1,s} & \text{if } \mathrm{cyc}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \mathrm{soc}^{0}_{\xi,s} & \text{if } \neg \mathrm{cyc}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{\xi,s} \\ \mathrm{soc}^{0}_{\xi,s} & \text{if } \mathrm{reset}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} ``` ### `Store_energy_carried_in` @@ -8522,7 +8667,7 @@ Store_energy_carried_in: ``` ```math -\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{t,v} \cdot e_{\xi,\left( t \ominus \mathrm{idle}^{e} \right) \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \mathrm{e}^{0}_{v} & \text{if } \neg \mathrm{cyc}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \rho^{e}_{t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{v} \\ \mathrm{e}^{0}_{v} & \text{if } \mathrm{reset}^{e}_{v} \wedge \neg \mathrm{cyc}^{e,y}_{v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} +\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{\xi,t,v} \cdot e_{\xi,\left( t \ominus \mathrm{idle}^{e} \right) \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \mathrm{e}^{0}_{\xi,v} & \text{if } \neg \mathrm{cyc}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \rho^{e}_{\xi,t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{\xi,v} \\ \mathrm{e}^{0}_{\xi,v} & \text{if } \mathrm{reset}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{\xi,t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} ``` ### `Link_output_arrival` @@ -8545,7 +8690,7 @@ Link_output_arrival: ``` ```math -\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` ### `Process_output_arrival` @@ -8568,7 +8713,7 @@ Process_output_arrival: ``` ```math -\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} ``` ### `GlobalConstraint_energy_weight` @@ -8579,7 +8724,7 @@ GlobalConstraint_energy_weight: what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not - dims: [global_constraint, snapshot] + dims: [scenario, global_constraint, snapshot] cases: counted: when: GlobalConstraint_counts_snapshot @@ -8588,7 +8733,7 @@ GlobalConstraint_energy_weight: ``` ```math -\mathit{w}^{\mathrm{gc}}_{i,t} = \begin{cases} \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{in}_{i,t} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T} +\mathit{w}^{\mathrm{gc}}_{\xi,i,t} = \begin{cases} \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{in}_{\xi,i,t} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I},\ t \in \mathcal{T} ``` ### `GlobalConstraint_snapshot_closes` @@ -8596,7 +8741,7 @@ GlobalConstraint_energy_weight: ```yaml GlobalConstraint_snapshot_closes: description: one at the last snapshot a row counts, and zero elsewhere - dims: [global_constraint, snapshot] + dims: [scenario, global_constraint, snapshot] cases: last_counted: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) @@ -8605,7 +8750,7 @@ GlobalConstraint_snapshot_closes: ``` ```math -\mathit{last}_{i,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{i,t} \wedge \neg \mathrm{in}_{i,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T} +\mathit{last}_{\xi,i,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{\xi,i,t} \wedge \neg \mathrm{in}_{\xi,i,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I},\ t \in \mathcal{T} ``` ### `StorageUnit_closing_weight` @@ -8617,7 +8762,7 @@ StorageUnit_closing_weight: closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere - dims: [global_constraint, snapshot, storage_unit] + dims: [scenario, global_constraint, snapshot, storage_unit] cases: per_period: when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 @@ -8629,7 +8774,7 @@ StorageUnit_closing_weight: ``` ```math -\mathit{w}^{h}_{i,t,s} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}_{s} \wedge \mathrm{in}_{i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{i,t} & \text{if } \neg \mathrm{reset}_{s} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T},\ s \in \mathcal{S} +\mathit{w}^{h}_{\xi,i,t,s} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}_{\xi,s} \wedge \mathrm{in}_{\xi,i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{\xi,i,t} & \text{if } \neg \mathrm{reset}_{\xi,s} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I},\ t \in \mathcal{T},\ s \in \mathcal{S} ``` ### `Store_closing_weight` @@ -8641,7 +8786,7 @@ Store_closing_weight: closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere - dims: [global_constraint, snapshot, store] + dims: [scenario, global_constraint, snapshot, store] cases: per_period: when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 @@ -8653,7 +8798,7 @@ Store_closing_weight: ``` ```math -\mathit{w}^{e}_{i,t,v} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}^{e}_{v} \wedge \mathrm{in}_{i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{i,t} & \text{if } \neg \mathrm{reset}^{e}_{v} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I},\ t \in \mathcal{T},\ v \in \mathcal{V} +\mathit{w}^{e}_{\xi,i,t,v} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}^{e}_{\xi,v} \wedge \mathrm{in}_{\xi,i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{\xi,i,t} & \text{if } \neg \mathrm{reset}^{e}_{\xi,v} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I},\ t \in \mathcal{T},\ v \in \mathcal{V} ``` ### `primary_energy` @@ -8672,7 +8817,7 @@ primary_energy: ``` ```math -\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{i,t} \cdot \mathrm{a}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{i,t,s} \cdot \mathrm{a}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{i,t,v} \cdot \mathrm{a}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{a}_{\xi,i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{a}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{a}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `operational_limit` @@ -8691,7 +8836,7 @@ operational_limit: ``` ```math -\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{i,t} \cdot \mathrm{b}_{i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{i,t,s} \cdot \mathrm{b}^{h}_{i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{i,t,v} \cdot \mathrm{b}^{e}_{i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{b}_{\xi,i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{b}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{b}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_volume_expansion` @@ -8705,7 +8850,7 @@ transmission_volume_expansion: ``` ```math -\mathit{transmission\_volume\_expansion}_{i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{i,l} \qquad \forall\, i \in \mathcal{I} +\mathit{transmission\_volume\_expansion}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{\xi,i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_expansion_cost` @@ -8719,7 +8864,7 @@ transmission_expansion_cost: ``` ```math -\mathit{transmission\_expansion\_cost}_{i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{i,l} \qquad \forall\, i \in \mathcal{I} +\mathit{transmission\_expansion\_cost}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{\xi,i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `tech_capacity_expansion` @@ -8771,7 +8916,7 @@ scenario_opex: ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` ### `Carrier_additions` @@ -8905,7 +9050,7 @@ h^{-}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T}, **`StorageUnit_spill`** ```math -0 \le \mathit{spill}_{\xi,t,s} \le \mathrm{inflow}_{t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{inflow}_{t,s} > 0 \wedge \mathrm{on}^{h}_{t,s} +0 \le \mathit{spill}_{\xi,t,s} \le \mathrm{inflow}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{inflow}_{\xi,t,s} > 0 \wedge \mathrm{on}^{h}_{t,s} ``` **`Store_e`** @@ -9166,7 +9311,7 @@ Generator_maintenance_events_positive: ``` ```math -\mathrm{n}^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +\mathrm{n}^{\mathrm{mnt}}_{\xi,g} > 0 \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} ``` ### `Generator_maintenance_duration_positive` @@ -9181,7 +9326,7 @@ Generator_maintenance_duration_positive: ``` ```math -\tau^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +\tau^{\mathrm{mnt}}_{\xi,g} > 0 \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} ``` ### `Generator_maintenance_duration_fits_the_horizon` @@ -9197,7 +9342,7 @@ Generator_maintenance_duration_fits_the_horizon: ``` ```math -\tau^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +\tau^{\mathrm{mnt}}_{\xi,g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} ``` ### `Generator_maintenance_events_fit_the_horizon` @@ -9213,7 +9358,7 @@ Generator_maintenance_events_fit_the_horizon: ``` ```math -\tau^{\mathrm{mnt}}_{g} \cdot \mathrm{n}^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +\tau^{\mathrm{mnt}}_{\xi,g} \cdot \mathrm{n}^{\mathrm{mnt}}_{\xi,g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} ``` ### `Generator_maintenance_build_cap_is_finite` @@ -9229,7 +9374,7 @@ Generator_maintenance_build_cap_is_finite: ``` ```math -\overline{\mathrm{p}}^{\mathrm{nom}}_{g} < \infty \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} +\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} < \infty \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} ``` ### `Generator_maintenance_module_count_is_finite` @@ -9246,7 +9391,7 @@ Generator_maintenance_module_count_is_finite: ``` ```math -\overline{\mathrm{p}}^{\mathrm{nom}}_{g} < \infty \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} < \infty \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 ``` ### `Link_maintenance_events_positive` @@ -9261,7 +9406,7 @@ Link_maintenance_events_positive: ``` ```math -\mathrm{n}^{f,\mathrm{mnt}}_{l} > 0 \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +\mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} > 0 \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} ``` ### `Link_maintenance_duration_positive` @@ -9276,7 +9421,7 @@ Link_maintenance_duration_positive: ``` ```math -\tau^{f,\mathrm{mnt}}_{l} > 0 \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +\tau^{f,\mathrm{mnt}}_{\xi,l} > 0 \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} ``` ### `Link_maintenance_duration_fits_the_horizon` @@ -9292,7 +9437,7 @@ Link_maintenance_duration_fits_the_horizon: ``` ```math -\tau^{f,\mathrm{mnt}}_{l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +\tau^{f,\mathrm{mnt}}_{\xi,l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} ``` ### `Link_maintenance_events_fit_the_horizon` @@ -9308,7 +9453,7 @@ Link_maintenance_events_fit_the_horizon: ``` ```math -\tau^{f,\mathrm{mnt}}_{l} \cdot \mathrm{n}^{f,\mathrm{mnt}}_{l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +\tau^{f,\mathrm{mnt}}_{\xi,l} \cdot \mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} ``` ### `Link_maintenance_build_cap_is_finite` @@ -9324,7 +9469,7 @@ Link_maintenance_build_cap_is_finite: ``` ```math -\overline{\mathrm{f}}^{\mathrm{nom}}_{l} < \infty \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} +\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} < \infty \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} ``` ### `Link_maintenance_module_count_is_finite` @@ -9341,7 +9486,7 @@ Link_maintenance_module_count_is_finite: ``` ```math -\overline{\mathrm{f}}^{\mathrm{nom}}_{l} < \infty \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} < \infty \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 ``` ### `Process_maintenance_events_positive` @@ -9356,7 +9501,7 @@ Process_maintenance_events_positive: ``` ```math -\mathrm{n}^{z,\mathrm{mnt}}_{j} > 0 \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +\mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} > 0 \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} ``` ### `Process_maintenance_duration_positive` @@ -9371,7 +9516,7 @@ Process_maintenance_duration_positive: ``` ```math -\tau^{z,\mathrm{mnt}}_{j} > 0 \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +\tau^{z,\mathrm{mnt}}_{\xi,j} > 0 \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} ``` ### `Process_maintenance_duration_fits_the_horizon` @@ -9387,7 +9532,7 @@ Process_maintenance_duration_fits_the_horizon: ``` ```math -\tau^{z,\mathrm{mnt}}_{j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +\tau^{z,\mathrm{mnt}}_{\xi,j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} ``` ### `Process_maintenance_events_fit_the_horizon` @@ -9403,7 +9548,7 @@ Process_maintenance_events_fit_the_horizon: ``` ```math -\tau^{z,\mathrm{mnt}}_{j} \cdot \mathrm{n}^{z,\mathrm{mnt}}_{j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +\tau^{z,\mathrm{mnt}}_{\xi,j} \cdot \mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} ``` ### `Process_maintenance_build_cap_is_finite` @@ -9419,7 +9564,7 @@ Process_maintenance_build_cap_is_finite: ``` ```math -\overline{\mathrm{z}}^{\mathrm{nom}}_{j} < \infty \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} +\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} < \infty \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} ``` ### `Process_maintenance_module_count_is_finite` @@ -9436,7 +9581,7 @@ Process_maintenance_module_count_is_finite: ``` ```math -\overline{\mathrm{z}}^{\mathrm{nom}}_{j} < \infty \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} < \infty \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 ``` ### `StorageUnit_stands_in_one_run` @@ -9546,7 +9691,7 @@ StorageUnit_primary_energy_per_period_closes_over_the_horizon: ``` ```math -\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{i,t} \} \rvert = 0 \qquad \forall\, s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{a}^{h}_{i,s} \text{ is defined} \wedge \mathrm{reset}_{s} +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{\xi,i,t} \} \rvert = 0 \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{a}^{h}_{\xi,i,s} \text{ is defined} \wedge \mathrm{reset}_{\xi,s} ``` ### `StorageUnit_primary_energy_carried_over_has_unit_years` @@ -9563,7 +9708,7 @@ StorageUnit_primary_energy_carried_over_has_unit_years: ``` ```math -\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, s \in \mathcal{S},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{h}_{i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{s} +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{h}_{\xi,i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{\xi,s} ``` ### `StorageUnit_operational_limit_carried_over_has_unit_years` @@ -9578,7 +9723,7 @@ StorageUnit_operational_limit_carried_over_has_unit_years: ``` ```math -\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{b}^{h}_{i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{s} \wedge \mathrm{in}_{i,t} +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S},\ i \in \mathcal{I} \,:\, \mathrm{b}^{h}_{\xi,i,s} \text{ is defined} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \mathrm{in}_{\xi,i,t} ``` ### `Store_primary_energy_per_period_closes_over_the_horizon` @@ -9594,7 +9739,7 @@ Store_primary_energy_per_period_closes_over_the_horizon: ``` ```math -\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{i,t} \} \rvert = 0 \qquad \forall\, v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{a}^{e}_{i,v} \text{ is defined} \wedge \mathrm{reset}^{e}_{v} +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{\xi,i,t} \} \rvert = 0 \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{a}^{e}_{\xi,i,v} \text{ is defined} \wedge \mathrm{reset}^{e}_{\xi,v} ``` ### `Store_primary_energy_carried_over_has_unit_years` @@ -9611,7 +9756,7 @@ Store_primary_energy_carried_over_has_unit_years: ``` ```math -\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, v \in \mathcal{V},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{e}_{i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{v} +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V},\ i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{e}_{\xi,i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{\xi,v} ``` ### `Store_operational_limit_carried_over_has_unit_years` @@ -9626,7 +9771,7 @@ Store_operational_limit_carried_over_has_unit_years: ``` ```math -\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{b}^{e}_{i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{v} \wedge \mathrm{in}_{i,t} +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V},\ i \in \mathcal{I} \,:\, \mathrm{b}^{e}_{\xi,i,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \mathrm{in}_{\xi,i,t} ``` ### `Generator_marginal_cost_quadratic_without_risk_preference` @@ -9643,7 +9788,7 @@ Generator_marginal_cost_quadratic_without_risk_preference: ``` ```math -\mathrm{c}^{(2)}_{t,g} = 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \omega > 0 +\mathrm{c}^{(2)}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \omega > 0 ``` ### `Link_marginal_cost_quadratic_without_risk_preference` @@ -9660,7 +9805,7 @@ Link_marginal_cost_quadratic_without_risk_preference: ``` ```math -\mathrm{c}^{f,(2)}_{t,l} = 0 \qquad \forall\, t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \omega > 0 +\mathrm{c}^{f,(2)}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \omega > 0 ``` ### `Process_marginal_cost_quadratic_without_risk_preference` @@ -9677,7 +9822,7 @@ Process_marginal_cost_quadratic_without_risk_preference: ``` ```math -\mathrm{c}^{z,(2)}_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \omega > 0 +\mathrm{c}^{z,(2)}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \omega > 0 ``` ### `StorageUnit_marginal_cost_quadratic_without_risk_preference` @@ -9694,7 +9839,7 @@ StorageUnit_marginal_cost_quadratic_without_risk_preference: ``` ```math -\mathrm{c}^{h,(2)}_{t,s} = 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \omega > 0 +\mathrm{c}^{h,(2)}_{\xi,t,s} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \omega > 0 ``` ### `Store_marginal_cost_quadratic_without_risk_preference` @@ -9711,7 +9856,7 @@ Store_marginal_cost_quadratic_without_risk_preference: ``` ```math -\mathrm{c}^{q,(2)}_{t,v} = 0 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \omega > 0 +\mathrm{c}^{q,(2)}_{\xi,t,v} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \omega > 0 ``` ### `GlobalConstraint_tech_capacity_expansion_limit_without_scenarios` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index a58374b9..d1cd6321 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -6,14 +6,16 @@ description: >- A plain `n.optimize()`, and its multi-period and stochastic classes, in one file. Every second-stage quantity spans a `scenario` (a future dispatch is chosen in) and every asset stands in the investment `period`s its build year - and lifetime span. Capacity is chosen once, before the future is known, and - paid once per active period; operation is the expectation over the scenarios' - weights, with a share priced at the tail through the CVaR rows, which stand - only where that share is positive. A plain run feeds one scenario, one period, all-active masks and unit weights, and the - model collapses to the standard one. A security-constrained run copies each - branch flow limit once per outage in an `outage` set that a plain run leaves - empty. Which snapshots an asset is active in, a scenario's weight, and the - outage factors are data prep. + and lifetime span. A parameter spans `scenario` exactly when PyPSA reads it + per scenario. Capacity is chosen once, before the future is known, and paid + once per active period at its cost in expectation over the scenarios; + operation is the expectation over the scenarios' weights, with a share priced + at the tail through the CVaR rows, which stand only where that share is + positive. A plain run feeds one scenario, one period, all-active masks and + unit weights, and the model collapses to the standard one. A + security-constrained run copies each branch flow limit once per outage in an + `outage` set that a plain run leaves empty. Which snapshots an asset is + active in, a scenario's weight, and the outage factors are data prep. dimensions: scenario: @@ -76,74 +78,74 @@ parameters: dims: [snapshot] Generator_p_nom: description: nominal power - dims: [generator] + dims: [scenario, generator] Generator_p_nom_extendable: description: whether the nominal power is a decision dims: [generator] dtype: bool Generator_p_min_pu: description: least output, per unit of nominal power - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Generator_p_max_pu: description: most output, per unit of nominal power — an availability profile - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Generator_marginal_cost: description: cost of one unit of output - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Generator_marginal_cost_quadratic: description: cost of the square of one unit of output - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Generator_committable: description: whether output is gated by an on/off status decision dims: [generator] dtype: bool Generator_ramp_limit_up: description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit - dims: [generator] + dims: [scenario, generator] Generator_ramp_limit_down: description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit - dims: [generator] + dims: [scenario, generator] Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power - dims: [generator] + dims: [scenario, generator] Generator_ramp_limit_shut_down: description: most output in the snapshot before a unit stops, per unit of nominal power - dims: [generator] + dims: [scenario, generator] Generator_min_up_time: description: least snapshots a unit stays on once started - dims: [generator] + dims: [scenario, generator] dtype: int Generator_min_down_time: description: least snapshots a unit stays off once stopped - dims: [generator] + dims: [scenario, generator] dtype: int Generator_status_initial: description: one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep - dims: [generator] + dims: [scenario, generator] dtype: int Generator_must_stay_up: description: >- true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] dtype: bool Generator_must_stay_down: description: >- true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] dtype: bool Generator_start_up_cost: description: cost of one start - dims: [generator] + dims: [scenario, generator] Generator_shut_down_cost: description: cost of one stop - dims: [generator] + dims: [scenario, generator] Generator_stand_by_cost: description: cost of one snapshot spent on - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Generator_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [generator] @@ -153,26 +155,28 @@ parameters: / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules - dims: [generator] + dims: [scenario, generator] Generator_big_m: description: a bound safely above any feasible output — the build cap at full availability, data prep - dims: [generator] + dims: [scenario, generator] Generator_p_min_pu_nonneg: description: >- true where none of the generator's own minimums-per-unit is negative — - PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep dims: [generator] dtype: bool Generator_maintainable: - description: whether a generator must be taken off for maintenance within the horizon + description: >- + whether a generator must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) dims: [generator] dtype: bool Generator_maintenance_pu: description: the share of the build a maintenance event takes off - dims: [generator] + dims: [scenario, generator] Generator_maintenance_events: description: how many maintenance events the horizon holds - dims: [generator] + dims: [scenario, generator] dtype: int Generator_maintenance_duration: description: >- @@ -181,105 +185,108 @@ parameters: maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon - dims: [generator] + dims: [scenario, generator] Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] dtype: bool Link_ramp_limit_up: description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit - dims: [link] + dims: [scenario, link] Link_ramp_limit_down: description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit - dims: [link] + dims: [scenario, link] Link_p_nom: description: nominal power - dims: [link] + dims: [scenario, link] Link_p_nom_extendable: description: whether the nominal power is a decision dims: [link] dtype: bool Link_p_min_pu: description: least flow, per unit of nominal power — negative for a link that carries both ways - dims: [snapshot, link] + dims: [scenario, snapshot, link] Link_p_max_pu: description: most flow, per unit of nominal power - dims: [snapshot, link] + dims: [scenario, snapshot, link] Link_efficiency: description: >- share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers - dims: [link_output] + dims: [scenario, link_output] Link_output_delay: description: >- snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which - the file states as whole snapshots; zero for a port that delivers at once + the file states as whole snapshots; zero for a port that delivers at once. + One for every scenario: PyPSA groups the ports by delay over all scenarios + and shifts each group in every one (`constraints.py:1269`) dims: [link_output] dtype: int Link_output_cyclic_delay: description: >- whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, - the flow still in transit at each period's first snapshots is lost + the flow still in transit at each period's first snapshots is lost. One + for every scenario, as the delay dims: [link_output] dtype: bool Link_marginal_cost: description: cost of one unit of flow - dims: [snapshot, link] + dims: [scenario, snapshot, link] Link_marginal_cost_quadratic: description: cost of the square of one unit of flow - dims: [snapshot, link] + dims: [scenario, snapshot, link] Link_committable: description: whether flow is gated by an on/off status decision dims: [link] dtype: bool Link_ramp_limit_start_up: description: most flow in the snapshot a link starts, per unit of nominal power - dims: [link] + dims: [scenario, link] Link_ramp_limit_shut_down: description: most flow in the snapshot before a link stops, per unit of nominal power - dims: [link] + dims: [scenario, link] Link_min_up_time: description: least snapshots a link stays on once started - dims: [link] + dims: [scenario, link] dtype: int Link_min_down_time: description: least snapshots a link stays off once stopped - dims: [link] + dims: [scenario, link] dtype: int Link_status_initial: description: one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep - dims: [link] + dims: [scenario, link] dtype: int Link_must_stay_up: description: >- true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter - dims: [snapshot, link] + dims: [scenario, snapshot, link] dtype: bool Link_must_stay_down: description: >- true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason - dims: [snapshot, link] + dims: [scenario, snapshot, link] dtype: bool Link_start_up_cost: description: cost of one start - dims: [link] + dims: [scenario, link] Link_shut_down_cost: description: cost of one stop - dims: [link] + dims: [scenario, link] Link_stand_by_cost: description: cost of one snapshot spent on - dims: [snapshot, link] + dims: [scenario, snapshot, link] Link_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [link] @@ -289,26 +296,28 @@ parameters: / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules - dims: [link] + dims: [scenario, link] Link_big_m: description: a bound safely above any feasible flow — the build cap at full availability, data prep - dims: [link] + dims: [scenario, link] Link_p_min_pu_nonneg: description: >- true where none of the link's own minimums-per-unit is negative — - PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep dims: [link] dtype: bool Link_maintainable: - description: whether a link must be taken off for maintenance within the horizon + description: >- + whether a link must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) dims: [link] dtype: bool Link_maintenance_pu: description: the share of the build a maintenance event takes off - dims: [link] + dims: [scenario, link] Link_maintenance_events: description: how many maintenance events the horizon holds - dims: [link] + dims: [scenario, link] dtype: int Link_maintenance_duration: description: >- @@ -317,122 +326,123 @@ parameters: maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon - dims: [link] + dims: [scenario, link] Link_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep - dims: [snapshot, link] + dims: [scenario, snapshot, link] dtype: bool Process_p_nom: description: nominal internal power - dims: [process] + dims: [scenario, process] Process_p_nom_extendable: description: whether the nominal internal power is a decision dims: [process] dtype: bool Process_p_min_pu: description: least internal power, per unit of nominal power — negative for a process that runs both ways - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_p_max_pu: description: most internal power, per unit of nominal power - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_rate: description: >- the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies - dims: [process_output] + dims: [scenario, process_output] Process_output_delay: description: >- snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that - transfers at once + transfers at once. One for every scenario, as a link's dims: [process_output] dtype: int Process_output_cyclic_delay: description: >- whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, - the energy still in transit at each period's first snapshots is lost + the energy still in transit at each period's first snapshots is lost. + One for every scenario, as the delay dims: [process_output] dtype: bool Process_marginal_cost: description: cost of one unit of internal power - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_marginal_cost_quadratic: description: cost of the square of one unit of internal power - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_ramp_limit_up: description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit - dims: [process] + dims: [scenario, process] Process_ramp_limit_down: description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit - dims: [process] + dims: [scenario, process] Process_p_set: description: a given internal power schedule; a process without one has no row here - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_p_nom_min: description: least nominal power an extendable process may be built at - dims: [process] + dims: [scenario, process] Process_p_nom_max: description: most nominal power an extendable process may be built at - dims: [process] + dims: [scenario, process] Process_capital_cost: description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [process] + dims: [scenario, process] Process_p_nom_set: description: a given nominal power for an extendable process; one without a value has no row here - dims: [process] + dims: [scenario, process] Process_committable: description: whether internal power is gated by an on/off status decision dims: [process] dtype: bool Process_ramp_limit_start_up: description: most internal power in the snapshot a process starts, per unit of nominal power - dims: [process] + dims: [scenario, process] Process_ramp_limit_shut_down: description: most internal power in the snapshot before a process stops, per unit of nominal power - dims: [process] + dims: [scenario, process] Process_min_up_time: description: least snapshots a process stays on once started - dims: [process] + dims: [scenario, process] dtype: int Process_min_down_time: description: least snapshots a process stays off once stopped - dims: [process] + dims: [scenario, process] dtype: int Process_status_initial: description: one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep - dims: [process] + dims: [scenario, process] dtype: int Process_must_stay_up: description: >- true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter - dims: [snapshot, process] + dims: [scenario, snapshot, process] dtype: bool Process_must_stay_down: description: >- true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason - dims: [snapshot, process] + dims: [scenario, snapshot, process] dtype: bool Process_start_up_cost: description: cost of one start - dims: [process] + dims: [scenario, process] Process_shut_down_cost: description: cost of one stop - dims: [process] + dims: [scenario, process] Process_stand_by_cost: description: cost of one snapshot spent on - dims: [snapshot, process] + dims: [scenario, snapshot, process] Process_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [process] @@ -442,26 +452,28 @@ parameters: / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules - dims: [process] + dims: [scenario, process] Process_big_m: description: a bound safely above any feasible internal power — the build cap at full availability, data prep - dims: [process] + dims: [scenario, process] Process_p_min_pu_nonneg: description: >- true where none of the process's own minimums-per-unit is negative — - PyPSA's per-unit `(p_min_pu >= 0).all()`, data prep + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep dims: [process] dtype: bool Process_maintainable: - description: whether a process must be taken off for maintenance within the horizon + description: >- + whether a process must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) dims: [process] dtype: bool Process_maintenance_pu: description: the share of the build a maintenance event takes off - dims: [process] + dims: [scenario, process] Process_maintenance_events: description: how many maintenance events the horizon holds - dims: [process] + dims: [scenario, process] dtype: int Process_maintenance_duration: description: >- @@ -470,14 +482,14 @@ parameters: maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon - dims: [process] + dims: [scenario, process] Process_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep - dims: [snapshot, process] + dims: [scenario, snapshot, process] dtype: bool Load_p_set: description: demand @@ -568,17 +580,22 @@ parameters: description: one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep dims: [period, process] Carrier_max_growth: - description: most capacity of a carrier that may be added in a period; no value means no limit + description: >- + most capacity of a carrier that may be added in a period; no value means + no limit. The least over the scenarios, as PyPSA takes it + (`global_constraints.py:226-230`), data prep dims: [carrier] Carrier_max_relative_growth: - description: share of the previous period's additions that may be added on top + description: >- + share of the previous period's additions that may be added on top — the + least over the scenarios, as PyPSA takes it, data prep dims: [carrier] Generator_p_set: description: a given output schedule; a generator without one has no row here - dims: [snapshot, generator] + dims: [scenario, snapshot, generator] Link_p_set: description: a given flow schedule; a link without one has no row here - dims: [snapshot, link] + dims: [scenario, snapshot, link] snapshot_weightings_stores: description: PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance dims: [snapshot] @@ -587,92 +604,92 @@ parameters: dims: [snapshot] Generator_p_nom_min: description: least nominal power an extendable generator may be built at - dims: [generator] + dims: [scenario, generator] Generator_p_nom_max: description: most nominal power an extendable generator may be built at - dims: [generator] + dims: [scenario, generator] Generator_capital_cost: description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [generator] + dims: [scenario, generator] Generator_p_nom_set: description: a given nominal power for an extendable generator; one without a value has no row here - dims: [generator] + dims: [scenario, generator] Generator_e_sum_min: description: least energy over the horizon; minus infinity where no floor is meant - dims: [generator] + dims: [scenario, generator] Generator_e_sum_max: description: most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant - dims: [generator] + dims: [scenario, generator] Link_p_nom_min: description: least nominal power an extendable link may be built at - dims: [link] + dims: [scenario, link] Link_p_nom_max: description: most nominal power an extendable link may be built at - dims: [link] + dims: [scenario, link] Link_capital_cost: description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [link] + dims: [scenario, link] Link_p_nom_set: description: a given nominal power for an extendable link; one without a value has no row here - dims: [link] + dims: [scenario, link] StorageUnit_p_nom_min: description: least nominal power an extendable storage unit may be built at - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_p_nom_max: description: most nominal power an extendable storage unit may be built at - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_capital_cost: description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_p_nom_set: description: a given nominal power for an extendable storage unit; one without a value has no row here - dims: [storage_unit] + dims: [scenario, storage_unit] Store_e_nom_min: description: least nominal capacity an extendable store may be built at - dims: [store] + dims: [scenario, store] Store_e_nom_max: description: most nominal capacity an extendable store may be built at - dims: [store] + dims: [scenario, store] Store_capital_cost: description: cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [store] + dims: [scenario, store] Store_e_nom_set: description: a given nominal capacity for an extendable store; one without a value has no row here - dims: [store] + dims: [scenario, store] StorageUnit_p_nom: description: nominal power - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_p_nom_extendable: description: whether the nominal power is a decision dims: [storage_unit] dtype: bool StorageUnit_p_min_pu: description: most storing, per unit of nominal power and negated - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_p_max_pu: description: most dispatch, per unit of nominal power - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_max_hours: description: energy capacity, as hours of dispatch at nominal power - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_efficiency_store: description: share of the power drawn from the bus that becomes charge - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_efficiency_dispatch: description: share of the charge drawn down that reaches the bus - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_retention: description: share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_inflow: description: energy arriving per hour, a river into a reservoir - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_state_of_charge_initial: description: charge held before the first snapshot - dims: [storage_unit] + dims: [scenario, storage_unit] StorageUnit_cyclic_state_of_charge: description: whether the horizon closes on itself instead of opening on the initial charge - dims: [storage_unit] + dims: [scenario, storage_unit] dtype: bool StorageUnit_cyclic_state_of_charge_per_period: description: >- @@ -680,14 +697,14 @@ parameters: charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise - dims: [storage_unit] + dims: [scenario, storage_unit] dtype: bool StorageUnit_state_of_charge_initial_per_period: description: >- whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise - dims: [storage_unit] + dims: [scenario, storage_unit] dtype: bool StorageUnit_opens_late: description: >- @@ -705,50 +722,50 @@ parameters: dtype: int StorageUnit_marginal_cost: description: cost of one unit of dispatch - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_marginal_cost_quadratic: description: cost of the square of one unit of dispatch; storing is not charged - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_marginal_cost_storage: description: cost of one unit of charge held over one snapshot - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_spill_cost: description: cost of one unit of inflow passed on unused - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_p_set: description: a given net dispatch schedule; a unit without one has no row here - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_p_dispatch_set: description: a given dispatch schedule; a unit without one has no row here - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_p_store_set: description: a given charging schedule; a unit without one has no row here - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_state_of_charge_set: description: a given charge schedule; a unit without one has no row here - dims: [snapshot, storage_unit] + dims: [scenario, snapshot, storage_unit] Store_e_nom: description: nominal energy capacity - dims: [store] + dims: [scenario, store] Store_e_nom_extendable: description: whether the nominal energy capacity is a decision dims: [store] dtype: bool Store_e_min_pu: description: least energy held, per unit of nominal capacity — negative for a store that may go short - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_e_max_pu: description: most energy held, per unit of nominal capacity - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_retention: description: share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_e_initial: description: energy held before the first snapshot - dims: [store] + dims: [scenario, store] Store_e_cyclic: description: whether the horizon closes on itself instead of opening on the initial energy - dims: [store] + dims: [scenario, store] dtype: bool Store_e_cyclic_per_period: description: >- @@ -756,14 +773,14 @@ parameters: energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise - dims: [store] + dims: [scenario, store] dtype: bool Store_e_initial_per_period: description: >- whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise - dims: [store] + dims: [scenario, store] dtype: bool Store_opens_late: description: >- @@ -781,48 +798,49 @@ parameters: dtype: int Store_marginal_cost: description: cost of one unit of power delivered - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_marginal_cost_quadratic: description: cost of the square of the net power delivered, so charging costs as much as delivering - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_marginal_cost_storage: description: cost of one unit of energy held over one snapshot - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_e_set: description: a given energy schedule; a store without one has no row here - dims: [snapshot, store] + dims: [scenario, snapshot, store] Store_p_set: description: a given schedule of power delivered; a store without one has no row here - dims: [snapshot, store] + dims: [scenario, snapshot, store] Line_s_nom: description: nominal apparent power - dims: [line] + dims: [scenario, line] Line_s_nom_extendable: description: whether the nominal apparent power is a decision dims: [line] dtype: bool Line_s_max_pu: description: most flow either way, per unit of nominal apparent power - dims: [snapshot, line] + dims: [scenario, snapshot, line] Line_s_nom_min: description: least nominal apparent power an extendable line may be built at - dims: [line] + dims: [scenario, line] Line_s_nom_max: description: most nominal apparent power an extendable line may be built at - dims: [line] + dims: [scenario, line] Line_capital_cost: description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [line] + dims: [scenario, line] Line_s_nom_set: description: a given nominal apparent power for an extendable line; one without a value has no row here - dims: [line] + dims: [scenario, line] Line_s_set: description: a given flow schedule; a line without one has no row here - dims: [snapshot, line] + dims: [scenario, snapshot, line] Line_cycle_weight: description: >- the line's series impedance, signed by its orientation in the cycle — - the cycle basis, data prep; a line in no cycle has no row + the cycle basis, data prep; a line in no cycle has no row. PyPSA builds + the cycle basis from the first scenario only (`networks.py:1354-1361`) dims: [line, cycle] Line_BODF: description: >- @@ -841,48 +859,49 @@ parameters: dtype: bool Line_loss_max: description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep - dims: [snapshot, line] + dims: [scenario, snapshot, line] Line_loss_slope: description: >- the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep - dims: [snapshot, line, segment] + dims: [scenario, snapshot, line, segment] Line_loss_offset: description: >- where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep - dims: [snapshot, line, segment] + dims: [scenario, snapshot, line, segment] Transformer_s_nom: description: nominal apparent power - dims: [transformer] + dims: [scenario, transformer] Transformer_s_nom_extendable: description: whether the nominal apparent power is a decision dims: [transformer] dtype: bool Transformer_s_max_pu: description: most flow either way, per unit of nominal apparent power - dims: [snapshot, transformer] + dims: [scenario, snapshot, transformer] Transformer_s_nom_min: description: least nominal apparent power an extendable transformer may be built at - dims: [transformer] + dims: [scenario, transformer] Transformer_s_nom_max: description: most nominal apparent power an extendable transformer may be built at - dims: [transformer] + dims: [scenario, transformer] Transformer_capital_cost: description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep - dims: [transformer] + dims: [scenario, transformer] Transformer_s_nom_set: description: a given nominal apparent power for an extendable transformer; one without a value has no row here - dims: [transformer] + dims: [scenario, transformer] Transformer_s_set: description: a given flow schedule; a transformer without one has no row here - dims: [snapshot, transformer] + dims: [scenario, snapshot, transformer] Transformer_cycle_weight: description: >- the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle - basis, data prep; a transformer in no cycle has no row + basis, data prep; a transformer in no cycle has no row. From the first + scenario only, as a line's dims: [transformer, cycle] Transformer_BODF: description: >- @@ -902,7 +921,9 @@ parameters: description: >- whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a - fixed shift carried by `phase_shift` + fixed shift carried by `phase_shift`. The shift parameters carry no + scenario: only a cycle row reads them, and PyPSA fails on a transformer in + a cycle on a network with scenarios (`constraints.py:1654`) dims: [transformer] dtype: bool Transformer_phase_shift_min: @@ -927,18 +948,18 @@ parameters: the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times the tap ratio, data prep - dims: [snapshot, transformer] + dims: [scenario, snapshot, transformer] Transformer_loss_slope: description: >- the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep - dims: [snapshot, transformer, segment] + dims: [scenario, snapshot, transformer, segment] Transformer_loss_offset: description: >- where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep - dims: [snapshot, transformer, segment] + dims: [scenario, snapshot, transformer, segment] GlobalConstraint_type: description: >- which formula the row takes — `primary_energy`, `operational_limit`, @@ -964,47 +985,51 @@ parameters: dims: [scenario, global_constraint] GlobalConstraint_counts_snapshot: description: >- - whether a row counts a snapshot — PyPSA's `investment_period`: every + whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) - dims: [global_constraint, snapshot] + dims: [scenario, global_constraint, snapshot] dtype: bool Generator_primary_energy_weight: description: >- the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row - dims: [global_constraint, generator] + dims: [scenario, global_constraint, generator] StorageUnit_primary_energy_weight: description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row - dims: [global_constraint, storage_unit] + dims: [scenario, global_constraint, storage_unit] Store_primary_energy_weight: description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row - dims: [global_constraint, store] + dims: [scenario, global_constraint, store] Generator_operational_limit_weight: description: one where the generator is in the row's set — data prep; one outside it has no row - dims: [global_constraint, generator] + dims: [scenario, global_constraint, generator] StorageUnit_operational_limit_weight: description: one where the storage unit is in the row's set — data prep; one outside it has no row - dims: [global_constraint, storage_unit] + dims: [scenario, global_constraint, storage_unit] Store_operational_limit_weight: description: one where the store is in the row's set — data prep; one outside it has no row - dims: [global_constraint, store] + dims: [scenario, global_constraint, store] Line_volume_weight: description: >- - the line's length where its carrier is in the row's set — data prep; a + the line's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row - dims: [global_constraint, line] + dims: [scenario, global_constraint, line] Link_volume_weight: description: >- - the link's length where its carrier is in the row's set — data prep; a + the link's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row - dims: [global_constraint, link] + dims: [scenario, global_constraint, link] Line_expansion_cost_weight: description: >- the line's capital cost where its carrier is in the row's set, times @@ -1012,7 +1037,7 @@ parameters: no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row - dims: [global_constraint, line] + dims: [scenario, global_constraint, line] Link_expansion_cost_weight: description: >- the link's capital cost where its carrier is in the row's set, times @@ -1020,7 +1045,7 @@ parameters: no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row - dims: [global_constraint, link] + dims: [scenario, global_constraint, link] Generator_tech_capacity_weight: description: >- one where the generator is in the row's carrier-and-bus set — data @@ -1155,25 +1180,28 @@ relations: values: transformer Generator_maintenance_cover: description: >- - the snapshots a maintenance event covers, by the snapshot it starts in — - the start and the snapshots after it, until their generator weightings - reach `maintenance_duration`, data prep; no rows for a generator that is + the snapshots a maintenance event covers in a scenario, by the snapshot it + starts in — the start and the snapshots after it, until their generator + weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a generator that is not maintainable - key: { generator: generator, start: snapshot, covered: snapshot } + key: { scenario: scenario, generator: generator, start: snapshot, covered: snapshot } Link_maintenance_cover: description: >- - the snapshots a maintenance event covers, by the snapshot it starts in — - the start and the snapshots after it, until their generator weightings - reach `maintenance_duration`, data prep; no rows for a link that is + the snapshots a maintenance event covers in a scenario, by the snapshot it + starts in — the start and the snapshots after it, until their generator + weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a link that is not maintainable - key: { link: link, start: snapshot, covered: snapshot } + key: { scenario: scenario, link: link, start: snapshot, covered: snapshot } Process_maintenance_cover: description: >- - the snapshots a maintenance event covers, by the snapshot it starts in — - the start and the snapshots after it, until their generator weightings - reach `maintenance_duration`, data prep; no rows for a process that is + the snapshots a maintenance event covers in a scenario, by the snapshot it + starts in — the start and the snapshots after it, until their generator + weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a process that is not maintainable - key: { process: process, start: snapshot, covered: snapshot } + key: { scenario: scenario, process: process, start: snapshot, covered: snapshot } variables: Generator_p: @@ -1554,7 +1582,7 @@ expressions: otherwise: shift(Generator_p, along=snapshot, offset=1) Generator_p_nom_effective: description: the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [generator] + dims: [scenario, generator] cases: extendable: { when: Generator_p_nom_extendable, expression: Generator_p_nom_ext } otherwise: Generator_p_nom @@ -1562,7 +1590,7 @@ expressions: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 @@ -1570,7 +1598,7 @@ expressions: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 @@ -1578,7 +1606,7 @@ expressions: description: >- the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } otherwise: 1 @@ -1586,7 +1614,7 @@ expressions: description: >- the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [generator] + dims: [scenario, generator] cases: given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } otherwise: 1 @@ -1594,7 +1622,7 @@ expressions: description: >- the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [generator] + dims: [scenario, generator] cases: modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } otherwise: Generator_p_nom @@ -1628,7 +1656,7 @@ expressions: otherwise: Generator_ramp_down_rate * Generator_p_nom_effective Link_p_nom_effective: description: the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [link] + dims: [scenario, link] cases: extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } otherwise: Link_p_nom @@ -1653,7 +1681,7 @@ expressions: description: >- the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } otherwise: 1 @@ -1661,7 +1689,7 @@ expressions: description: >- the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } otherwise: 1 @@ -1669,7 +1697,7 @@ expressions: description: >- the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } otherwise: 1 @@ -1677,7 +1705,7 @@ expressions: description: >- the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [link] + dims: [scenario, link] cases: given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } otherwise: 1 @@ -1685,7 +1713,7 @@ expressions: description: >- the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [link] + dims: [scenario, link] cases: modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } otherwise: Link_p_nom @@ -1719,7 +1747,7 @@ expressions: otherwise: Link_ramp_down_rate * Link_p_nom_effective Process_p_nom_effective: description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise - dims: [process] + dims: [scenario, process] cases: extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } otherwise: Process_p_nom @@ -1744,7 +1772,7 @@ expressions: description: >- the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } otherwise: 1 @@ -1752,7 +1780,7 @@ expressions: description: >- the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } otherwise: 1 @@ -1760,7 +1788,7 @@ expressions: description: >- the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } otherwise: 1 @@ -1768,7 +1796,7 @@ expressions: description: >- the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none - dims: [process] + dims: [scenario, process] cases: given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } otherwise: 1 @@ -1776,7 +1804,7 @@ expressions: description: >- the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise - dims: [process] + dims: [scenario, process] cases: modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } otherwise: Process_p_nom @@ -1907,7 +1935,7 @@ expressions: what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not - dims: [global_constraint, snapshot] + dims: [scenario, global_constraint, snapshot] cases: counted: when: GlobalConstraint_counts_snapshot @@ -1915,7 +1943,7 @@ expressions: otherwise: 0 GlobalConstraint_snapshot_closes: description: one at the last snapshot a row counts, and zero elsewhere - dims: [global_constraint, snapshot] + dims: [scenario, global_constraint, snapshot] cases: last_counted: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) @@ -1927,7 +1955,7 @@ expressions: closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere - dims: [global_constraint, snapshot, storage_unit] + dims: [scenario, global_constraint, snapshot, storage_unit] cases: per_period: when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 @@ -1942,7 +1970,7 @@ expressions: closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere - dims: [global_constraint, snapshot, store] + dims: [scenario, global_constraint, snapshot, store] cases: per_period: when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 @@ -2093,18 +2121,18 @@ constraints: where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) Generator_ext_p_nom_lower: - description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [generator] + description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, generator] where: Generator_p_nom_extendable expression: Generator_p_nom_ext >= Generator_p_nom_min Generator_ext_p_nom_upper: - description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [generator] + description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, generator] where: Generator_p_nom_extendable AND Generator_p_nom_max expression: Generator_p_nom_ext <= Generator_p_nom_max Generator_p_nom_set: description: "`Generator-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [generator] + dims: [scenario, generator] where: Generator_p_nom_extendable AND Generator_p_nom_set expression: Generator_p_nom_ext == Generator_p_nom_set Generator_e_sum_min: @@ -2128,18 +2156,18 @@ constraints: where: Link_p_nom_extendable AND not Link_committable AND Link_active expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) Link_ext_p_nom_lower: - description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [link] + description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, link] where: Link_p_nom_extendable expression: Link_p_nom_ext >= Link_p_nom_min Link_ext_p_nom_upper: - description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [link] + description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, link] where: Link_p_nom_extendable AND Link_p_nom_max expression: Link_p_nom_ext <= Link_p_nom_max Link_p_nom_set: description: "`Link-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [link] + dims: [scenario, link] where: Link_p_nom_extendable AND Link_p_nom_set expression: Link_p_nom_ext == Link_p_nom_set Process_fix_p_lower: @@ -2163,18 +2191,18 @@ constraints: where: Process_p_nom_extendable AND not Process_committable AND Process_active expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) Process_ext_p_nom_lower: - description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [process] + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, process] where: Process_p_nom_extendable expression: Process_p_nom_ext >= Process_p_nom_min Process_ext_p_nom_upper: - description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [process] + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, process] where: Process_p_nom_extendable AND Process_p_nom_max expression: Process_p_nom_ext <= Process_p_nom_max Process_p_nom_set: description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [process] + dims: [scenario, process] where: Process_p_nom_extendable AND Process_p_nom_set expression: Process_p_nom_ext == Process_p_nom_set StorageUnit_fix_p_dispatch_lower: @@ -3040,18 +3068,18 @@ constraints: where: Line_s_nom_extendable AND Line_active expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext Line_ext_s_nom_lower: - description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [line] + description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, line] where: Line_s_nom_extendable expression: Line_s_nom_ext >= Line_s_nom_min Line_ext_s_nom_upper: - description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [line] + description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, line] where: Line_s_nom_extendable AND Line_s_nom_max expression: Line_s_nom_ext <= Line_s_nom_max Line_s_nom_set: description: "`Line-s_nom_set` — the chosen build pinned, wherever a value is given" - dims: [line] + dims: [scenario, line] where: Line_s_nom_extendable AND Line_s_nom_set expression: Line_s_nom_ext == Line_s_nom_set Line_s_set: @@ -3101,18 +3129,18 @@ constraints: where: Transformer_s_nom_extendable AND Transformer_active expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom_ext Transformer_ext_s_nom_lower: - description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor" - dims: [transformer] + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, transformer] where: Transformer_s_nom_extendable expression: Transformer_s_nom_ext >= Transformer_s_nom_min Transformer_ext_s_nom_upper: - description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [transformer] + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, transformer] where: Transformer_s_nom_extendable AND Transformer_s_nom_max expression: Transformer_s_nom_ext <= Transformer_s_nom_max Transformer_s_nom_set: description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" - dims: [transformer] + dims: [scenario, transformer] where: Transformer_s_nom_extendable AND Transformer_s_nom_set expression: Transformer_s_nom_ext == Transformer_s_nom_set Transformer_s_set: @@ -3357,18 +3385,18 @@ constraints: where: StorageUnit_p_nom_extendable AND StorageUnit_active expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom_ext StorageUnit_ext_p_nom_lower: - description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor" - dims: [storage_unit] + description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable expression: StorageUnit_p_nom_ext >= StorageUnit_p_nom_min StorageUnit_ext_p_nom_upper: - description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [storage_unit] + description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_max expression: StorageUnit_p_nom_ext <= StorageUnit_p_nom_max StorageUnit_p_nom_set: description: "`StorageUnit-p_nom_set` — the chosen build pinned, wherever a value is given" - dims: [storage_unit] + dims: [scenario, storage_unit] where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_set expression: StorageUnit_p_nom_ext == StorageUnit_p_nom_set StorageUnit_energy_balance: @@ -3405,18 +3433,18 @@ constraints: where: Store_e_nom_extendable AND Store_active expression: Store_e <= Store_e_max_pu * Store_e_nom_ext Store_ext_e_nom_lower: - description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor" - dims: [store] + description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, store] where: Store_e_nom_extendable expression: Store_e_nom_ext >= Store_e_nom_min Store_ext_e_nom_upper: - description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap; a cap of infinity is no row" - dims: [store] + description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, store] where: Store_e_nom_extendable AND Store_e_nom_max expression: Store_e_nom_ext <= Store_e_nom_max Store_e_nom_set: description: "`Store-e_nom_set` — the chosen build pinned, wherever a value is given" - dims: [store] + dims: [scenario, store] where: Store_e_nom_extendable AND Store_e_nom_set expression: Store_e_nom_ext == Store_e_nom_set Store_energy_balance: @@ -3856,14 +3884,16 @@ assumptions: objective: sense: minimize - description: capacity once per active period, operation in expectation over the scenarios, and a share of it at the tail + description: >- + capacity once per active period at its expected cost over the scenarios, operation in + expectation over the scenarios, and a share of it at the tail expression: >- - sum(Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + + sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + + sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + + sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + + sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + + sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + + sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index a68d6c36..d9c66993 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1592,5 +1592,44 @@ "Link-fix-p-lower": 8, "Link-fix-p-upper": 8 } + }, + "rung_41_scenario_operational_data": { + "columns": { + "Generator-p": 24, + "Link-p": 16 + }, + "global_constraints": {}, + "objective": 17964.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 16, + "Generator-fix-p-lower": 24, + "Generator-fix-p-upper": 24, + "Link-fix-p-lower": 16, + "Link-fix-p-upper": 16 + } + }, + "rung_42_scenario_first_stage": { + "columns": { + "Generator-p": 24, + "Generator-p_nom": 1, + "Link-p": 8 + }, + "global_constraints": {}, + "objective": 5049.999999999999, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 16, + "Generator-ext-p-lower": 8, + "Generator-ext-p-upper": 8, + "Generator-ext-p_nom-lower": 2, + "Generator-ext-p_nom-upper": 2, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_41_scenario_operational_data.py b/examples/references/pypsa/rung_41_scenario_operational_data.py new file mode 100644 index 00000000..890bd560 --- /dev/null +++ b/examples/references/pypsa/rung_41_scenario_operational_data.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 41: a unit's cost and a link's efficiency differ by scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own value, per component and attribute +PER_SCENARIO = { + ('calm', 'gas41'): {'marginal_cost': 20}, + ('stormy', 'gas41'): {'marginal_cost': 80}, + ('calm', 'wire41'): {'efficiency': 0.9}, + ('stormy', 'wire41'): {'efficiency': 0.6}, +} + + +def build(): + """The spine over two futures, with a gas unit that costs more and a link that delivers less in the stormy one.""" + n = spine.build() + n.add('Generator', 'gas41', bus='south', p_nom=100, marginal_cost=20) + n.add('Link', 'wire41', bus0='north', bus1='south', p_nom=40, efficiency=0.9) + n.add('Load', 'port41', bus='south', p_set=60) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for (scenario, name), values in PER_SCENARIO.items(): + component = n.c.generators if name == 'gas41' else n.c.links + for column, value in values.items(): + component.static.loc[(scenario, name), column] = value + return n diff --git a/examples/references/pypsa/rung_42_scenario_first_stage.py b/examples/references/pypsa/rung_42_scenario_first_stage.py new file mode 100644 index 00000000..73efdecc --- /dev/null +++ b/examples/references/pypsa/rung_42_scenario_first_stage.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 42: an extendable unit's capital cost and build cap differ by scenario.""" + +from __future__ import annotations + +import spine + +#: each scenario's own value, per attribute of the extendable unit +PER_SCENARIO = { + 'calm': {'capital_cost': 20, 'p_nom_max': 100}, + 'stormy': {'capital_cost': 60, 'p_nom_max': 30}, +} + + +def build(): + """The spine over two futures, with an extendable wind unit that costs more and may be built less in the stormy one.""" + n = spine.build() + n.add('Generator', 'wind42', bus='south', p_nom_extendable=True, p_nom_max=100, marginal_cost=1, capital_cost=20) + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + for scenario, values in PER_SCENARIO.items(): + for column, value in values.items(): + n.c.generators.static.loc[(scenario, 'wind42'), column] = value + return n diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index c91d5b3d..b8d0161d 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -75,17 +75,26 @@ def test_a_standard_constraint_changes_frame_only_by_a_leading_scenario(): assert not wrong, f'a standard row may gain only a leading scenario; these differ: {wrong}' -#: the standard parameters PyPSA reads per scenario -SCENARIO_PARAMETERS = {'Load_p_set', 'GlobalConstraint_sense', 'GlobalConstraint_constant'} - - -def test_only_the_scenario_parameters_carry_scenario_among_standard_parameters(): +def test_a_standard_parameter_changes_frame_only_by_a_leading_scenario(): wrong = { name: _dims(ALL.parameters[name]) for name, dims in STANDARD['parameters'].items() - if _dims(ALL.parameters[name]) != (['scenario', *dims] if name in SCENARIO_PARAMETERS else dims) + if _dims(ALL.parameters[name]) not in (dims, ['scenario', *dims]) } - assert not wrong, f'only {sorted(SCENARIO_PARAMETERS)} gain scenario; these differ: {wrong}' + assert not wrong, f'a standard parameter may gain only a leading scenario; these differ: {wrong}' + + +#: PyPSA refuses a difference across scenarios in these attributes and in what is derived from them +INVARIANT_SUFFIXES = ('_extendable', '_committable', '_nom_mod', '_active', '_capital_weight', '_type') + + +def test_what_pypsa_holds_equal_across_scenarios_spans_no_scenario(): + spanning = sorted( + name + for name, block in ALL.parameters.items() + if name.endswith(INVARIANT_SUFFIXES) and 'scenario' in _dims(block) + ) + assert not spanning, f'PyPSA refuses these to differ by scenario, so they carry no scenario: {spanning}' def test_the_extra_axes_and_rows_are_declared(): From 15c2f43c59a5941e9c06ec79a6f7c0b9db3850ce Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 07:24:44 +0200 Subject: [PATCH 32/75] docs(pypsa): a component's sign turns its term in the bus balance around --- docs/examples/pypsa.md | 77 +++++++++++++++++++++-- examples/pypsa.yaml | 35 +++++++++-- examples/references/pypsa/references.json | 32 ++++++++++ examples/references/pypsa/rung_43_sign.py | 22 +++++++ examples/symbols/pypsa.yaml | 4 ++ 5 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 examples/references/pypsa/rung_43_sign.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index ea99fdb6..9d9c1f82 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -85,6 +85,7 @@ def build(): | [`Link-fix-p-lower`](#link-fix-p-lower) | done | | | [`Link-fix-p-upper`](#link-fix-p-upper) | done | | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | +| [`Bus-nodal_balance`](#bus-nodal_balance) with a component `sign` | done | rung 43 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | | [`marginal_cost`](#objective) | done | | | [`marginal_cost_quadratic`](#objective) | done | rungs 10 and 36, below; Generator and Link `p`, Process `p`, StorageUnit `p_dispatch` only, and Store net `p` | @@ -3578,6 +3579,65 @@ def build(): +### Rung 43 — a component's sign + +`n.optimize()` with a generator, a load, a storage unit and a store whose +`sign` is not PyPSA's default. PyPSA multiplies each of their terms in +`Bus-nodal_balance` by that `sign`: a generator's `p`, a storage unit's +`p_dispatch` and `p_store`, a store's `p` (`constraints.py:1428-1429`), and a +load's `p_set` on the constant side (`constraints.py:1538`). It reads `sign` +nowhere else in the model. The default is `1` for a generator, a storage unit +and a store, and `-1` for a load. PyPSA refuses a `sign` that differs by +scenario (`consistency.py:1187`), so the file states `Generator_sign`, +`Load_sign`, `StorageUnit_sign` and `Store_sign` without `scenario`. A plain run +feeds PyPSA's defaults, and the row collapses to the standard one. + +The rung adds a unit that draws `20` from its bus at a cost of `-50`, a storage +unit that opens full and a full store, each with a `sign` of `-1`, and a load of +`10` with a `sign` of `1`, which feeds its bus. Each sign binds. With the default sign on the +unit, PyPSA solves to `-5652.78`; on the load, to `3925.0`; on the storage unit, +to `1187.5`; on the store, to `925.0`; on all four, to `-5255.56`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Bus-nodal_balance`](#bus-nodal_balance) with a component `sign` | done | `Generator_sign`, `StorageUnit_sign` and `Store_sign` times each term, and `Load_sign` times the load, negated | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `2125.0`, 80 rows. + +
+The network, as PyPSA code + +`rung_43_sign.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 43: a component's `sign` turns its term in the bus balance around.""" + +from __future__ import annotations + +import spine + +#: the sign each component enters the bus balance with, against PyPSA's default +SIGNS = {'generators': ('flex43', -1), 'loads': ('feed43', 1), 'storage_units': ('su43', -1), 'stores': ('e43', -1)} + + +def build(): + """The spine with a unit that draws power, a load that feeds it, and a storage unit and a store drawn the other way round.""" + n = spine.build() + n.add('Generator', 'flex43', bus='south', p_nom=20, marginal_cost=-50, sign=-1) + n.add('Load', 'feed43', bus='north', p_set=10, sign=1) + n.add('StorageUnit', 'su43', bus='south', p_nom=10, max_hours=2, state_of_charge_initial=20, sign=-1) + n.add('Store', 'e43', bus='north', e_nom=30, e_initial=30, sign=-1) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3646,6 +3706,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | | $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | +| $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | | $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | | $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | @@ -3736,6 +3797,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | +| $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | | $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | @@ -3792,6 +3854,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | | $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\Xi \times \mathcal{S}`$ — share of the power drawn from the bus that becomes charge | | $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\Xi \times \mathcal{S}`$ — share of the charge drawn down that reaches the bus | +| $`\mathrm{sgn}^{h}`$ | `StorageUnit_sign` over $`\mathcal{S}`$ — the sign net dispatch enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\rho`$ | `StorageUnit_retention` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | | $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | | $`\mathrm{soc}^{0}`$ | `StorageUnit_state_of_charge_initial` over $`\Xi \times \mathcal{S}`$ — charge held before the first snapshot | @@ -3812,6 +3875,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | | $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | | $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | +| $`\mathrm{sgn}^{q}`$ | `Store_sign` over $`\mathcal{V}`$ — the sign the power a store delivers enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\rho^{e}`$ | `Store_retention` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | | $`\mathrm{e}^{0}`$ | `Store_e_initial` over $`\Xi \times \mathcal{V}`$ — energy held before the first snapshot | | $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\Xi \times \mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | @@ -7962,14 +8026,15 @@ Bus_nodal_balance: process port drawing or delivering at its own rate and each passive branch carrying its flow, meets the load there, less half of every incident line's and transformer's loss — PyPSA dissipates a branch's loss half at - either end. + either end. Each generator, storage unit, store and load term enters + with its component's `sign` (`constraints.py:1428-1429`, `:1538`). A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus, over=storage_unit, into=bus) - + sum(Store_p, by=Store_bus, over=store, into=bus) + sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + + sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) @@ -7981,11 +8046,11 @@ Bus_nodal_balance: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{\xi,t,d} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index d1cd6321..3577a30a 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -95,6 +95,12 @@ parameters: Generator_marginal_cost_quadratic: description: cost of the square of one unit of output dims: [scenario, snapshot, generator] + Generator_sign: + description: >- + the sign output enters its bus's balance with — PyPSA's `sign`, `1` + unless given, `-1` for a unit that draws power. PyPSA refuses one that + differs by scenario (`consistency.py:1187`) + dims: [generator] Generator_committable: description: whether output is gated by an on/off status decision dims: [generator] @@ -494,6 +500,12 @@ parameters: Load_p_set: description: demand dims: [scenario, snapshot, load] + Load_sign: + description: >- + the sign a load's demand enters its bus's balance with — PyPSA's + `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA + refuses one that differs by scenario (`consistency.py:1187`) + dims: [load] scenario_weight: description: PyPSA's `scenario_weightings.weight` — the probability of a future dims: [scenario] @@ -678,6 +690,12 @@ parameters: StorageUnit_efficiency_dispatch: description: share of the charge drawn down that reaches the bus dims: [scenario, storage_unit] + StorageUnit_sign: + description: >- + the sign net dispatch enters its bus's balance with — PyPSA's `sign`, + `1` unless given. PyPSA refuses one that differs by scenario + (`consistency.py:1187`) + dims: [storage_unit] StorageUnit_retention: description: share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep dims: [scenario, snapshot, storage_unit] @@ -757,6 +775,12 @@ parameters: Store_e_max_pu: description: most energy held, per unit of nominal capacity dims: [scenario, snapshot, store] + Store_sign: + description: >- + the sign the power a store delivers enters its bus's balance with — + PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by + scenario (`consistency.py:1187`) + dims: [store] Store_retention: description: share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep dims: [scenario, snapshot, store] @@ -3583,14 +3607,15 @@ constraints: process port drawing or delivering at its own rate and each passive branch carrying its flow, meets the load there, less half of every incident line's and transformer's loss — PyPSA dissipates a branch's loss half at - either end. + either end. Each generator, storage unit, store and load term enters + with its component's `sign` (`constraints.py:1428-1429`, `:1538`). A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) - + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus, over=storage_unit, into=bus) - + sum(Store_p, by=Store_bus, over=store, into=bus) + sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + + sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) @@ -3602,7 +3627,7 @@ constraints: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) Carrier_growth_limit: description: >- `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index d9c66993..31b0d2e2 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1631,5 +1631,37 @@ "Link-fix-p-lower": 8, "Link-fix-p-upper": 8 } + }, + "rung_43_sign": { + "columns": { + "Generator-p": 12, + "Link-p": 4, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Store-e": 4, + "Store-p": 4 + }, + "global_constraints": {}, + "objective": 2125.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "StorageUnit-energy_balance": 4, + "StorageUnit-fix-p_dispatch-lower": 4, + "StorageUnit-fix-p_dispatch-upper": 4, + "StorageUnit-fix-p_store-lower": 4, + "StorageUnit-fix-p_store-upper": 4, + "StorageUnit-fix-state_of_charge-lower": 4, + "StorageUnit-fix-state_of_charge-upper": 4, + "Store-energy_balance": 4, + "Store-fix-e-lower": 4, + "Store-fix-e-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_43_sign.py b/examples/references/pypsa/rung_43_sign.py new file mode 100644 index 00000000..3c86108a --- /dev/null +++ b/examples/references/pypsa/rung_43_sign.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 43: a component's `sign` turns its term in the bus balance around.""" + +from __future__ import annotations + +import spine + +#: the sign each component enters the bus balance with, against PyPSA's default +SIGNS = {'generators': ('flex43', -1), 'loads': ('feed43', 1), 'storage_units': ('su43', -1), 'stores': ('e43', -1)} + + +def build(): + """The spine with a unit that draws power, a load that feeds it, and a storage unit and a store drawn the other way round.""" + n = spine.build() + n.add('Generator', 'flex43', bus='south', p_nom=20, marginal_cost=-50, sign=-1) + n.add('Load', 'feed43', bus='north', p_set=10, sign=1) + n.add('StorageUnit', 'su43', bus='south', p_nom=10, max_hours=2, state_of_charge_initial=20, sign=-1) + n.add('Store', 'e43', bus='north', e_nom=30, e_initial=30, sign=-1) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index f6789c47..4ed2b4fc 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -32,6 +32,7 @@ names: Generator_p_max_pu: '\overline{\mathrm{p}}' Generator_marginal_cost: '\mathrm{c}' Generator_marginal_cost_quadratic: '\mathrm{c}^{(2)}' + Generator_sign: '\mathrm{sgn}' Generator_committable: '\mathrm{com}' Generator_ramp_limit_up: '\mathrm{ru}' Generator_ramp_limit_down: '\mathrm{rd}' @@ -103,6 +104,7 @@ names: Link_big_m: '\mathrm{M}^{f}' Link_p_min_pu_nonneg: '\mathrm{nonneg}^{f}' Load_p_set: '\mathrm{load}' + Load_sign: '\mathrm{sgn}^{\mathrm{load}}' Generator_p_set: '\mathrm{p}^{\mathrm{set}}' Link_p_set: '\mathrm{f}^{\mathrm{set}}' snapshot_weightings_stores: '\mathrm{w}^{\mathrm{sto}}' @@ -252,6 +254,7 @@ names: StorageUnit_max_hours: '\mathrm{T}^{h}' StorageUnit_efficiency_store: '\eta^{-}' StorageUnit_efficiency_dispatch: '\eta^{+}' + StorageUnit_sign: '\mathrm{sgn}^{h}' StorageUnit_retention: '\rho' StorageUnit_inflow: '\mathrm{inflow}' StorageUnit_state_of_charge_initial: '\mathrm{soc}^{0}' @@ -275,6 +278,7 @@ names: Store_e_nom_extendable: '\mathrm{ext}^{e}' Store_e_min_pu: '\underline{\mathrm{e}}' Store_e_max_pu: '\overline{\mathrm{e}}' + Store_sign: '\mathrm{sgn}^{q}' Store_retention: '\rho^{e}' Store_e_initial: '\mathrm{e}^{0}' Store_energy_carried_in: '\overleftarrow{e}' From 49c9e850db1f125115d9ffdd6f3f691812728ab3 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 07:31:27 +0200 Subject: [PATCH 33/75] docs(pypsa): the linearized commitment file keeps a unit down, ramps at the full build where a limit is missing, and schedules maintenance --- docs/examples/pypsa_linearized_uc.md | 430 +++++++++++++++++- examples/pypsa.yaml | 18 +- examples/pypsa_linearized_uc.yaml | 197 +++++++- examples/references/pypsa/references.json | 39 ++ .../pypsa/rung_44_linearized_commitment.py | 55 +++ examples/symbols/pypsa_linearized_uc.yaml | 14 + 6 files changed, 712 insertions(+), 41 deletions(-) create mode 100644 examples/references/pypsa/rung_44_linearized_commitment.py diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 6ef90674..634322e5 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -5,8 +5,8 @@ SPDX-License-Identifier: CC-BY-4.0 # PyPSA, the relaxed commitment -Rung 12 of [PyPSA in one file](pypsa.md): `n.optimize(linearized_unit_commitment=True)`, stated on rungs 1 and 7 in a -file of its own. Its network is the spine plus the script's own additions. +Rungs 12 and 44 of [PyPSA in one file](pypsa.md): `n.optimize(linearized_unit_commitment=True)`, stated on rungs 1 and 7 +in a file of its own. Each network is the spine plus the script's own additions. ## Rung 12 — linearized unit commitment @@ -88,6 +88,112 @@ def build(): +## Rung 44 — the integer file's commitment rows, relaxed + +`n.optimize(linearized_unit_commitment=True)` builds the same commitment rows as +the integer run, except the four tightening rows: the keyword only relaxes the +status, start and stop to shares in [0, 1] (`variables.py:81-88`) and adds the +tightening rows (`constraints.py:650`). So this file states each row that +[PyPSA in one file](pypsa.md) states for a fixed committable generator: + +- A unit still serving the down time it brought in stays off + (`constraints.py:622-628`). +- A ramp row stands where a unit has a ramp limit or only a start-up or + shut-down ramp, and a missing limit reads as the full build + (`constraints.py:1046-1055`). The earlier file built no row for a start-up + ramp alone, and read a missing start-up or shut-down ramp as `0`, which kept + a unit that came in off from starting. +- A maintainable unit is taken off for its events. The maintenance start stays + a binary under the keyword (`variables.py:234-259`), and the product of + status and maintenance enters the commitment rows through the `maint-status` + rows (`constraints.py:425-458`). A maintainable unit that is not committable + loses the same share of its fixed rows (`constraints.py:135-145`). + +The rung adds five cheap units under a swinging load. A start costs more than a +stop, so PyPSA does not tighten them and the rung isolates these rows. Each +binds: PyPSA solves to `7400.0`. Without the brought-in down time it solves to +`6540.0`; without the start-up ramp, to `7164.0`; with the missing start-up and +shut-down ramps read as `0`, to `8060.0`; with the committable unit not +maintainable, to `7310.0`; with the fixed unit not maintainable, to `7100.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | | +| [`Generator-p-ramp_limit_up`](#generator-p-ramp_limit_up), [`-down`](#generator-p-ramp_limit_down) for a start-up or shut-down ramp alone, and a missing limit | done | `Generator_ramp_up_rate` and the other three rates read a missing limit as `1` | +| [`Generator-maint-*`](#generator-maint-event-count), [`Generator-maint-status-*`](#generator-maint-status-le-status) | done | fixed units only, as the file states no extendable build | +| [`Generator-com-p-lower`](#generator-com-p-lower), [`-upper`](#generator-com-p-upper), [`Generator-fix-p-lower`](#generator-fix-p-lower), [`-upper`](#generator-fix-p-upper) in maintenance | done | | +| [`Generator-com-p-before`](#generator-com-p-before), [`-current`](#generator-com-p-current), [`-partly-start-up`](#generator-com-partly-start-up), [`-partly-shut-down`](#generator-com-partly-shut-down) with a missing ramp limit | out | PyPSA reads a missing limit as the full build (`constraints.py:311-316`); the file reads it as `0` | +| a ramp row for a unit that is not committable | out | PyPSA builds it with the status fixed at `1` (`constraints.py:1073-1079`); the file states ramp rows for committable units only | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7400.0`, 191 rows. + +
+The network, as PyPSA code + +`rung_44_linearized_commitment.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 44: linearized commitment with the rows the integer file has — a unit that must stay down, ramps read at the full build where a limit is missing, and maintenance.""" + +from __future__ import annotations + +import spine + +MODEL = 'pypsa_linearized_uc.yaml' +OPTIMIZE = {'linearized_unit_commitment': True} + +#: a start costs more than a stop, so PyPSA does not tighten these units and the rung isolates the rows it adds +UNTIGHTENED = {'committable': True, 'up_time_before': 0, 'start_up_cost': 1} + + +def build(): + """The spine plus five cheap units on a north bus with a swinging load: each carries one of the rows under review.""" + n = spine.build() + n.add( + 'Generator', + 'down44', + bus='north', + p_nom=40, + marginal_cost=2, + min_down_time=3, + down_time_before=1, + **UNTIGHTENED, + ) + n.add('Generator', 'pulse44', bus='north', p_nom=40, marginal_cost=3, ramp_limit_start_up=0.4, **UNTIGHTENED) + n.add( + 'Generator', + 'ramp44', + bus='north', + p_nom=40, + marginal_cost=4, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + **UNTIGHTENED, + ) + n.add( + 'Generator', + 'maint44', + bus='north', + p_nom=40, + p_min_pu=0.3, + marginal_cost=5, + maintainable=True, + maintenance_duration=2, + **UNTIGHTENED, + ) + n.add('Generator', 'fixed44', bus='north', p_nom=20, marginal_cost=1, maintainable=True, maintenance_duration=1) + n.add('Load', 'swing44', bus='north', p_set=[40, 120, 160, 60]) + return n +``` + +
+ + ## The file @@ -97,9 +203,9 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | Symbol | Meaning | |---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N},\ \mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | -| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N},\ \mathrm{Generator\_maintenance\_cover} \subseteq \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — generating units, each on one bus | | $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | | $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | @@ -109,6 +215,7 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | Symbol | Meaning | |---|---| | $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total | | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | | $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | | $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | @@ -128,10 +235,16 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\mathcal{G}`$ — cost of one start | | $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\mathcal{G}`$ — cost of one stop | | $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | | $`\mathrm{tight}`$ | `Generator_partly_tightened` over $`\mathcal{G}`$ — whether the four tightening rows below apply — PyPSA adds them only where a unit's start-up and shut-down costs are equal; two parameters cannot be compared in a `where`, so the equality is data prep | +| $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\mathcal{G}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\mathcal{G}`$ — how many maintenance events the horizon holds | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | #### Variables @@ -142,6 +255,9 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`u`$ | `Generator_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on, a share in \[0, 1\] rather than a binary: the relaxation `linearized_unit_commitment` solves | | $`\mathit{up}`$ | `Generator_start_up` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot | +| $`\mu`$ | `Generator_maintenance` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot — a binary, which the keyword does not relax | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | #### Definitions @@ -149,6 +265,10 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state |---|---| | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | $`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. @@ -179,11 +299,11 @@ Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" dims: [snapshot, generator] where: not Generator_committable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} +p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} ``` ### `Generator-fix-p-upper` @@ -195,11 +315,11 @@ Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a generator outputs at most what is available" dims: [snapshot, generator] where: not Generator_committable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} +p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( 1 - \gamma_{g} \cdot \mu_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} ``` ### `Link-fix-p-lower` @@ -263,11 +383,11 @@ Generator_com_p_lower: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" dims: [snapshot, generator] where: Generator_committable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +p_{t,g} \ge \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \gamma_{g} \cdot \mu^{u}_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} ``` ### `Generator-com-p-upper` @@ -279,11 +399,11 @@ Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" dims: [snapshot, generator] where: Generator_committable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) ``` ```math -p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} +p_{t,g} \le \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \gamma_{g} \cdot \mu^{u}_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} ``` ### `Generator-com-transition-start-up` @@ -343,7 +463,10 @@ Generator_com_up_time: ```yaml Generator_com_down_time: - description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries dims: [snapshot, generator] where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status @@ -369,6 +492,24 @@ Generator_com_status_must_stay_up: u_{t,g} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{t,g} ``` +### `Generator-com-status-min_down_time_must_stay_up` + +`Generator_com_status_must_stay_down` + +```yaml +Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [snapshot, generator] + where: Generator_committable AND Generator_must_stay_down + expression: Generator_status == 0 +``` + +```math +u_{t,g} = 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rest}_{t,g} +``` + ### `Generator-p-ramp_limit_up` `Generator_p_ramp_limit_up` @@ -383,17 +524,17 @@ Generator_p_ramp_limit_up: first snapshot dims: [snapshot, generator] where: >- - Generator_committable AND Generator_ramp_limit_up + Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_up * Generator_p_nom * Generator_previous_status - + Generator_ramp_limit_start_up * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom * (Generator_status - Generator_previous_status) ``` ```math -p_{t,g} - \overleftarrow{p}_{t,g} \le \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{t,g} + \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \overleftarrow{u}_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ru}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +p_{t,g} - \overleftarrow{p}_{t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \overleftarrow{u}_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) ``` ### `Generator-p-ramp_limit_down` @@ -410,17 +551,17 @@ Generator_p_ramp_limit_down: first snapshot dims: [snapshot, generator] where: >- - Generator_committable AND Generator_ramp_limit_down + Generator_committable AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_down * Generator_p_nom * Generator_status - + Generator_ramp_limit_shut_down * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom * Generator_status + + Generator_shut_down_rate * Generator_p_nom * (Generator_previous_status - Generator_status) ``` ```math -\overleftarrow{p}_{t,g} - p_{t,g} \le \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{t,g} - u_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rd}_{g} \text{ is defined} \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +\overleftarrow{p}_{t,g} - p_{t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{t,g} - u_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) ``` ### `Generator-status-p-fixed-upper` @@ -471,6 +612,105 @@ Generator_shut_down_p_fixed_upper: \mathit{dn}_{t,g} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} ``` +### `Generator-maint-event-count` + +`Generator_maint_event_count` + +```yaml +Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events +``` + +```math +\sum_{t \in \mathcal{T}} \mu^{\mathrm{up}}_{t,g} = \mathrm{n}^{\mathrm{mnt}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator-maint-window` + +`Generator_maint_window` + +```yaml +Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [snapshot, generator] + where: Generator_maintainable + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) +``` + +```math +\mu_{t,g} = \sum_{t' \in \mathcal{T} \,:\, \left( g,\ t',\ t \right) \in \mathrm{Generator\_maintenance\_cover}} \mu^{\mathrm{up}}_{t',g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator-maint-start-horizon` + +`Generator_maint_start_horizon` + +```yaml +Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 +``` + +```math +\mu^{\mathrm{up}}_{t,g} = 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{blk}_{t,g} +``` + +### `Generator-maint-status-le-status` + +`Generator_maint_status_le_status` + +```yaml +Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status <= Generator_status +``` + +```math +\mu^{u}_{t,g} \le u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} +``` + +### `Generator-maint-status-le-maint` + +`Generator_maint_status_le_maint` + +```yaml +Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status <= Generator_maintenance +``` + +```math +\mu^{u}_{t,g} \le \mu_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} +``` + +### `Generator-maint-status-lb` + +`Generator_maint_status_lb` + +```yaml +Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 +``` + +```math +\mu^{u}_{t,g} \ge u_{t,g} + \mu_{t,g} - 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} +``` + ### `Generator-com-p-before` `Generator_com_p_before` @@ -590,6 +830,74 @@ Generator_previous_p: \overleftarrow{p}_{t,g} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ p_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +### `Generator_ramp_up_rate` + +```yaml +Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}_{g} = \begin{cases} \mathrm{ru}_{g} & \text{if } \mathrm{ru}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_ramp_down_rate` + +```yaml +Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}_{g} = \begin{cases} \mathrm{rd}_{g} & \text{if } \mathrm{rd}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_start_up_rate` + +```yaml +Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} = \begin{cases} \mathrm{ru}^{\mathrm{up}}_{g} & \text{if } \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + +### `Generator_shut_down_rate` + +```yaml +Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 +``` + +```math +\widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +``` + #### Variable domains **`Generator_p`** @@ -621,6 +929,86 @@ u_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \math ```math \mathit{dn}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} ``` + +**`Generator_maintenance`** + +```math +0 \le \mu_{t,g} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_start`** + +```math +\mu^{\mathrm{up}}_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_status`** + +```math +\mu^{u}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} +``` + +### `Generator_maintenance_events_positive` + +```yaml +Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) +``` + +```math +\mathrm{n}^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_duration_positive` + +```yaml +Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} > 0 \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_duration_fits_the_horizon` + +```yaml +Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +### `Generator_maintenance_events_fit_the_horizon` + +```yaml +Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) +``` + +```math +\tau^{\mathrm{mnt}}_{g} \cdot \mathrm{n}^{\mathrm{mnt}}_{g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 3577a30a..2a361987 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -1204,25 +1204,25 @@ relations: values: transformer Generator_maintenance_cover: description: >- - the snapshots a maintenance event covers in a scenario, by the snapshot it - starts in — the start and the snapshots after it, until their generator - weightings reach that scenario's `maintenance_duration`, data prep; no + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no rows for a generator that is not maintainable key: { scenario: scenario, generator: generator, start: snapshot, covered: snapshot } Link_maintenance_cover: description: >- - the snapshots a maintenance event covers in a scenario, by the snapshot it - starts in — the start and the snapshots after it, until their generator - weightings reach that scenario's `maintenance_duration`, data prep; no + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no rows for a link that is not maintainable key: { scenario: scenario, link: link, start: snapshot, covered: snapshot } Process_maintenance_cover: description: >- - the snapshots a maintenance event covers in a scenario, by the snapshot it - starts in — the start and the snapshots after it, until their generator - weightings reach that scenario's `maintenance_duration`, data prep; no + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no rows for a process that is not maintainable key: { scenario: scenario, process: process, start: snapshot, covered: snapshot } diff --git a/examples/pypsa_linearized_uc.yaml b/examples/pypsa_linearized_uc.yaml index 2d09651c..6c11d5c4 100644 --- a/examples/pypsa_linearized_uc.yaml +++ b/examples/pypsa_linearized_uc.yaml @@ -32,6 +32,9 @@ parameters: snapshot_weightings_objective: description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost dims: [snapshot] + snapshot_weightings_generators: + description: PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total + dims: [snapshot] Generator_p_nom: description: nominal power dims: [generator] @@ -100,6 +103,13 @@ parameters: parameter dims: [snapshot, generator] dtype: bool + Generator_must_stay_down: + description: >- + true while the down time a unit brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [snapshot, generator] + dtype: bool Generator_start_up_cost: description: cost of one start dims: [generator] @@ -116,6 +126,33 @@ parameters: compared in a `where`, so the equality is data prep dims: [generator] dtype: bool + Generator_maintainable: + description: whether a generator must be taken off for maintenance within the horizon + dims: [generator] + dtype: bool + Generator_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [generator] + Generator_maintenance_events: + description: how many maintenance events the horizon holds + dims: [generator] + dtype: int + Generator_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the generator is not + maintainable. No row reads it: data prep turns it into + `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [generator] + Generator_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the generator does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [snapshot, generator] + dtype: bool relations: Generator_bus: @@ -141,6 +178,13 @@ relations: description: the bus a load sits on key: load values: bus + Generator_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + the start and the snapshots after it, until their generator weightings + reach `maintenance_duration`, data prep; no rows for a generator that is + not maintainable + key: { generator: generator, start: snapshot, covered: snapshot } variables: Generator_p: @@ -173,6 +217,34 @@ variables: where: Generator_committable bounds: lower: 0 + Generator_maintenance: + description: >- + `Generator-maintenance` — whether a maintainable generator is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [snapshot, generator] + where: Generator_maintainable + absence: zero + bounds: + lower: 0 + upper: 1 + Generator_maintenance_start: + description: >- + `Generator-maintenance_start` — whether a maintenance event starts in + this snapshot — a binary, which the keyword does not relax + dims: [snapshot, generator] + where: Generator_maintainable + absence: zero + domain: binary + Generator_maintenance_status: + description: >- + `Generator-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + absence: zero + bounds: + lower: 0 expressions: Generator_previous_status: @@ -192,18 +264,50 @@ expressions: cases: opening: { when: "position(snapshot) == 0", expression: 0 } otherwise: shift(Generator_p, along=snapshot, offset=1) + Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 + Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 + Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 + Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 constraints: Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a generator outputs at least its minimum" dims: [snapshot, generator] where: not Generator_committable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) Generator_fix_p_upper: description: "`Generator-fix-p-upper` — a generator outputs at most what is available" dims: [snapshot, generator] where: not Generator_committable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) Link_fix_p_lower: description: "`Link-fix-p-lower` — a link carries at least its minimum, negative for the other way" dims: [snapshot, link] @@ -227,12 +331,12 @@ constraints: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" dims: [snapshot, generator] where: Generator_committable - expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_com_p_upper: description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" dims: [snapshot, generator] where: Generator_committable - expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * Generator_status + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) Generator_com_transition_start_up: description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" dims: [snapshot, generator] @@ -252,7 +356,10 @@ constraints: where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status Generator_com_down_time: - description: "`Generator-com-down-time` — a unit stopped within its own minimum down time is still off" + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries dims: [snapshot, generator] where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status @@ -261,6 +368,13 @@ constraints: dims: [snapshot, generator] where: Generator_committable AND Generator_must_stay_up expression: Generator_status == 1 + Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [snapshot, generator] + where: Generator_committable AND Generator_must_stay_down + expression: Generator_status == 0 Generator_p_ramp_limit_up: description: >- `Generator-p-ramp_limit_up` — a committed unit raises output no faster @@ -270,12 +384,12 @@ constraints: first snapshot dims: [snapshot, generator] where: >- - Generator_committable AND Generator_ramp_limit_up + Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND (position(snapshot) > 0 OR Generator_status_initial == 0) expression: >- Generator_p - Generator_previous_p <= - Generator_ramp_limit_up * Generator_p_nom * Generator_previous_status - + Generator_ramp_limit_start_up * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom * (Generator_status - Generator_previous_status) Generator_p_ramp_limit_down: description: >- @@ -286,12 +400,12 @@ constraints: first snapshot dims: [snapshot, generator] where: >- - Generator_committable AND Generator_ramp_limit_down + Generator_committable AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND (position(snapshot) > 0 OR Generator_status_initial == 0) expression: >- Generator_previous_p - Generator_p <= - Generator_ramp_limit_down * Generator_p_nom * Generator_status - + Generator_ramp_limit_shut_down * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom * Generator_status + + Generator_shut_down_rate * Generator_p_nom * (Generator_previous_status - Generator_status) Generator_status_p_fixed_upper: description: "`Generator-status-p-fixed-upper` — a status is at most one, an explicit row as PyPSA writes it" @@ -308,6 +422,39 @@ constraints: dims: [snapshot, generator] where: Generator_committable expression: Generator_shut_down <= 1 + Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events + Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [snapshot, generator] + where: Generator_maintainable + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) + Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 + Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status <= Generator_status + Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status <= Generator_maintenance + Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [snapshot, generator] + where: Generator_maintainable AND Generator_committable + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 Generator_com_p_before: description: >- `Generator-com-p-before` — the output a unit had entering this snapshot @@ -358,3 +505,31 @@ objective: + sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective) + sum(Generator_start_up * Generator_start_up_cost) + sum(Generator_shut_down * Generator_shut_down_cost) + +assumptions: + Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 31b0d2e2..97342eaa 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1663,5 +1663,44 @@ "Store-fix-e-lower": 4, "Store-fix-e-upper": 4 } + }, + "rung_44_linearized_commitment": { + "columns": { + "Generator-maintenance": 8, + "Generator-maintenance_start": 8, + "Generator-maintenance_status": 4, + "Generator-p": 28, + "Generator-shut_down": 16, + "Generator-start_up": 16, + "Generator-status": 16, + "Link-p": 4 + }, + "global_constraints": {}, + "objective": 7400.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-com-down-time": 3, + "Generator-com-p-lower": 16, + "Generator-com-p-upper": 16, + "Generator-com-status-min_down_time_must_stay_up": 2, + "Generator-com-transition-shut-down": 16, + "Generator-com-transition-start-up": 16, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Generator-maint-event-count": 2, + "Generator-maint-status-lb": 4, + "Generator-maint-status-le-maint": 4, + "Generator-maint-status-le-status": 4, + "Generator-maint-window": 8, + "Generator-p-ramp_limit_down": 4, + "Generator-p-ramp_limit_up": 8, + "Generator-shut_down-p-fixed-upper": 16, + "Generator-start_up-p-fixed-upper": 16, + "Generator-status-p-fixed-upper": 16, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_44_linearized_commitment.py b/examples/references/pypsa/rung_44_linearized_commitment.py new file mode 100644 index 00000000..f51b5e57 --- /dev/null +++ b/examples/references/pypsa/rung_44_linearized_commitment.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 44: linearized commitment with the rows the integer file has — a unit that must stay down, ramps read at the full build where a limit is missing, and maintenance.""" + +from __future__ import annotations + +import spine + +MODEL = 'pypsa_linearized_uc.yaml' +OPTIMIZE = {'linearized_unit_commitment': True} + +#: a start costs more than a stop, so PyPSA does not tighten these units and the rung isolates the rows it adds +UNTIGHTENED = {'committable': True, 'up_time_before': 0, 'start_up_cost': 1} + + +def build(): + """The spine plus five cheap units on a north bus with a swinging load: each carries one of the rows under review.""" + n = spine.build() + n.add( + 'Generator', + 'down44', + bus='north', + p_nom=40, + marginal_cost=2, + min_down_time=3, + down_time_before=1, + **UNTIGHTENED, + ) + n.add('Generator', 'pulse44', bus='north', p_nom=40, marginal_cost=3, ramp_limit_start_up=0.4, **UNTIGHTENED) + n.add( + 'Generator', + 'ramp44', + bus='north', + p_nom=40, + marginal_cost=4, + ramp_limit_up=0.5, + ramp_limit_down=0.5, + **UNTIGHTENED, + ) + n.add( + 'Generator', + 'maint44', + bus='north', + p_nom=40, + p_min_pu=0.3, + marginal_cost=5, + maintainable=True, + maintenance_duration=2, + **UNTIGHTENED, + ) + n.add('Generator', 'fixed44', bus='north', p_nom=20, marginal_cost=1, maintainable=True, maintenance_duration=1) + n.add('Load', 'swing44', bus='north', p_set=[40, 120, 160, 60]) + return n diff --git a/examples/symbols/pypsa_linearized_uc.yaml b/examples/symbols/pypsa_linearized_uc.yaml index 3e9c5bce..a6516c3f 100644 --- a/examples/symbols/pypsa_linearized_uc.yaml +++ b/examples/symbols/pypsa_linearized_uc.yaml @@ -43,3 +43,17 @@ names: Link_marginal_cost: '\mathrm{c}^{f}' Load_p_set: '\mathrm{load}' Generator_partly_tightened: '\mathrm{tight}' + snapshot_weightings_generators: '\mathrm{w}^{\mathrm{gen}}' + Generator_must_stay_down: '\mathrm{rest}' + Generator_ramp_up_rate: '\widetilde{\mathrm{ru}}' + Generator_ramp_down_rate: '\widetilde{\mathrm{rd}}' + Generator_start_up_rate: '\widetilde{\mathrm{ru}}^{\mathrm{up}}' + Generator_shut_down_rate: '\widetilde{\mathrm{rd}}^{\mathrm{dn}}' + Generator_maintainable: '\mathrm{mnt}' + Generator_maintenance_pu: '\gamma' + Generator_maintenance_events: '\mathrm{n}^{\mathrm{mnt}}' + Generator_maintenance_duration: '\tau^{\mathrm{mnt}}' + Generator_maintenance_start_blocked: '\mathrm{blk}' + Generator_maintenance: '\mu' + Generator_maintenance_start: '\mu^{\mathrm{up}}' + Generator_maintenance_status: '\mu^{u}' From 19b1992da0ea1d60d5a15a0b52e1e2f2ea318732 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 07:58:55 +0200 Subject: [PATCH 34/75] docs(pypsa): a ramp limit may change over time and lift at a snapshot --- docs/examples/pypsa.md | 180 +++++++++++++----- examples/pypsa.yaml | 36 ++-- examples/references/pypsa/references.json | 26 +++ .../pypsa/rung_45_ramp_per_snapshot.py | 44 +++++ tests/fixtures/pypsa_standard_shape.yaml | 6 + 5 files changed, 226 insertions(+), 66 deletions(-) create mode 100644 examples/references/pypsa/rung_45_ramp_per_snapshot.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 9d9c1f82..828c800d 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -377,6 +377,7 @@ def build(): | PyPSA | status | note | | ------------------------------ | ------ | ---------------------------------------------------------- | | [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) | done | the build, the allowance and the output carried in are cased quantities, so fixed, extendable and committed are one block; big-M is rung 8's. A missing limit reads as the full build, and a start-up or shut-down ramp alone builds the row, rung 28 | +| [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) with a limit per snapshot | done | rung 45 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `8785.0`, 64 rows. @@ -3638,6 +3639,89 @@ def build(): +### Rung 45 — ramp limits per snapshot + +`n.optimize()` with a generator, a link and a process whose `ramp_limit_up` +and `ramp_limit_down` change over time. PyPSA declares both `static or series` +for all three components, and `ramp_limit_start_up` and `ramp_limit_shut_down` +static. The row between two snapshots reads the limit at the later snapshot +(`constraints.py:1040-1041`, `1109-1110`, `1139-1140`). The "no limit" test is +made per snapshot (`constraints.py:1046-1047`), and a missing value reads as +the full build there (`constraints.py:1052-1055`). So the file states +`{c}_ramp_limit_up` and `{c}_ramp_limit_down` over `snapshot`, and +`{c}_ramp_up_rate` and `{c}_ramp_down_rate` with them. A snapshot without a +value has no row in the table, so the `where:` drops the row there unless a +start-up or shut-down ramp builds it. A plain run feeds the same value at each +snapshot, and the rows collapse to the standard ones. + +The rung adds a steep bus with a load of `90` at the third snapshot. Each unit +may raise its output by `0.1` of its build, and by `0.5` into the third +snapshot. It may lower its output by `0.1`, and without a limit into the last +snapshot. With the constant limit `0.1` in each direction, the backup covers +the peak, and PyPSA solves to `90871.0` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) with a limit per snapshot | done | `{c}_ramp_limit_up`, `{c}_ramp_limit_down` and their rates span `snapshot`; a snapshot without a value drops the row unless a start-up or shut-down ramp builds it | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `10996.0`, 83 rows. + +
+The network, as PyPSA code + +`rung_45_ramp_per_snapshot.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 45: ramp limits per snapshot — a generator, a link and a process whose ramp limits change over time, and lift at one snapshot.""" + +from __future__ import annotations + +import math + +import spine + + +def build(): + """The spine plus a steep bus served by a generator, a link and a process with ramp limits per snapshot, with a dear backup.""" + n = spine.build() + n.add('Bus', 'steep') + up = [0.1, 0.1, 0.5, 0.1] + down = [0.1, 0.1, 0.1, math.nan] + n.add('Generator', 'steep_gen', bus='steep', p_nom=60, marginal_cost=3, ramp_limit_up=up, ramp_limit_down=down) + n.add( + 'Link', + 'steep_link', + bus0='north', + bus1='steep', + p_nom=40, + marginal_cost=4, + ramp_limit_up=up, + ramp_limit_down=down, + ) + n.add( + 'Process', + 'steep_proc', + bus0='south', + bus1='steep', + rate0=-1.25, + p_nom=40, + marginal_cost=5, + ramp_limit_up=up, + ramp_limit_down=down, + ) + n.add('Generator', 'steep_backup', bus='steep', p_nom=200, marginal_cost=500) + n.add('Load', 'steep_load', bus='steep', p_set=[10, 20, 90, 10]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3708,8 +3792,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | | $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | | $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | @@ -3729,8 +3813,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\Xi \times \mathcal{G}`$ — how many maintenance events the horizon holds | | $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | -| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\Xi \times \mathcal{L}`$ — nominal power | | $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$ — whether the nominal power is a decision | | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | @@ -3769,8 +3853,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. One for every scenario, as the delay | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | | $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | -| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | | $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$ — least nominal power an extendable process may be built at | | $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$ — most nominal power an extendable process may be built at | @@ -4001,8 +4085,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | | $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\Xi \times \mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | -| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\Xi \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\Xi \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\Xi \times \mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | | $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\Xi \times \mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | | $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\Xi \times \mathcal{G}`$ — the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | @@ -4011,8 +4095,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\Xi \times \mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — nothing at the start of the horizon, which is why a link that came in running carries no ramp row there | -| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\Xi \times \mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\Xi \times \mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\Xi \times \mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | | $`\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}`$ | `Link_shut_down_rate` over $`\Xi \times \mathcal{L}`$ — the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | | $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\Xi \times \mathcal{L}`$ — the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | @@ -4021,8 +4105,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\Xi \times \mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — nothing at the start of the horizon, which is why a process that came in running carries no ramp row there | -| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\Xi \times \mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\Xi \times \mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\Xi \times \mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | | $`\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}`$ | `Process_shut_down_rate` over $`\Xi \times \mathcal{J}`$ — the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | | $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\Xi \times \mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | @@ -4711,7 +4795,7 @@ Generator_p_ramp_limit_up_run_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -4737,7 +4821,7 @@ Generator_p_ramp_limit_up_start_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -4763,7 +4847,7 @@ Generator_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -4789,7 +4873,7 @@ Generator_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -5399,7 +5483,7 @@ Link_p_ramp_limit_up_run_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-start-bigM` @@ -5425,7 +5509,7 @@ Link_p_ramp_limit_up_start_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-run-bigM` @@ -5451,7 +5535,7 @@ Link_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-shut-bigM` @@ -5477,7 +5561,7 @@ Link_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p_nom_modularity` @@ -6087,7 +6171,7 @@ Process_p_ramp_limit_up_run_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-start-bigM` @@ -6113,7 +6197,7 @@ Process_p_ramp_limit_up_start_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-run-bigM` @@ -6139,7 +6223,7 @@ Process_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-shut-bigM` @@ -6165,7 +6249,7 @@ Process_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p_nom_modularity` @@ -7200,7 +7284,7 @@ Generator_p_ramp_limit_up: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -7225,7 +7309,7 @@ Generator_p_ramp_limit_down: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -7250,7 +7334,7 @@ Link_p_ramp_limit_up: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -7275,7 +7359,7 @@ Link_p_ramp_limit_down: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -7300,7 +7384,7 @@ Process_p_ramp_limit_up: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -7325,7 +7409,7 @@ Process_p_ramp_limit_down: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -8165,14 +8249,14 @@ Generator_ramp_up_rate: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, generator] + dims: [scenario, snapshot, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}_{\xi,g} = \begin{cases} \mathrm{ru}_{\xi,g} & \text{if } \mathrm{ru}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +\widetilde{\mathrm{ru}}_{\xi,t,g} = \begin{cases} \mathrm{ru}_{\xi,t,g} & \text{if } \mathrm{ru}_{\xi,t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_rate` @@ -8182,14 +8266,14 @@ Generator_ramp_down_rate: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, generator] + dims: [scenario, snapshot, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}_{\xi,g} = \begin{cases} \mathrm{rd}_{\xi,g} & \text{if } \mathrm{rd}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +\widetilde{\mathrm{rd}}_{\xi,t,g} = \begin{cases} \mathrm{rd}_{\xi,t,g} & \text{if } \mathrm{rd}_{\xi,t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_start_up_rate` @@ -8263,7 +8347,7 @@ Generator_ramp_up_allowance: ``` ```math -\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{\xi,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_allowance` @@ -8286,7 +8370,7 @@ Generator_ramp_down_allowance: ``` ```math -\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{\xi,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Link_p_nom_effective` @@ -8346,14 +8430,14 @@ Link_ramp_up_rate: description: >- the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, link] + dims: [scenario, snapshot, link] cases: given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{f}_{\xi,l} = \begin{cases} \mathrm{ru}^{f}_{\xi,l} & \text{if } \mathrm{ru}^{f}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +\widetilde{\mathrm{ru}}^{f}_{\xi,t,l} = \begin{cases} \mathrm{ru}^{f}_{\xi,t,l} & \text{if } \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_ramp_down_rate` @@ -8363,14 +8447,14 @@ Link_ramp_down_rate: description: >- the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, link] + dims: [scenario, snapshot, link] cases: given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{f}_{\xi,l} = \begin{cases} \mathrm{rd}^{f}_{\xi,l} & \text{if } \mathrm{rd}^{f}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +\widetilde{\mathrm{rd}}^{f}_{\xi,t,l} = \begin{cases} \mathrm{rd}^{f}_{\xi,t,l} & \text{if } \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_start_up_rate` @@ -8444,7 +8528,7 @@ Link_ramp_up_allowance: ``` ```math -\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{\xi,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_ramp_down_allowance` @@ -8467,7 +8551,7 @@ Link_ramp_down_allowance: ``` ```math -\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{\xi,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Process_p_nom_effective` @@ -8527,14 +8611,14 @@ Process_ramp_up_rate: description: >- the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, process] + dims: [scenario, snapshot, process] cases: given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}^{z}_{\xi,j} = \begin{cases} \mathrm{ru}^{z}_{\xi,j} & \text{if } \mathrm{ru}^{z}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +\widetilde{\mathrm{ru}}^{z}_{\xi,t,j} = \begin{cases} \mathrm{ru}^{z}_{\xi,t,j} & \text{if } \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_ramp_down_rate` @@ -8544,14 +8628,14 @@ Process_ramp_down_rate: description: >- the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, process] + dims: [scenario, snapshot, process] cases: given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}^{z}_{\xi,j} = \begin{cases} \mathrm{rd}^{z}_{\xi,j} & \text{if } \mathrm{rd}^{z}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +\widetilde{\mathrm{rd}}^{z}_{\xi,t,j} = \begin{cases} \mathrm{rd}^{z}_{\xi,t,j} & \text{if } \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_start_up_rate` @@ -8625,7 +8709,7 @@ Process_ramp_up_allowance: ``` ```math -\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{\xi,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_ramp_down_allowance` @@ -8648,7 +8732,7 @@ Process_ramp_down_allowance: ``` ```math -\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{\xi,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `StorageUnit_charge_carried_in` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 2a361987..dc64f276 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -106,11 +106,11 @@ parameters: dims: [generator] dtype: bool Generator_ramp_limit_up: - description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, generator] + description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] Generator_ramp_limit_down: - description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, generator] + description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [scenario, generator] @@ -201,11 +201,11 @@ parameters: dims: [scenario, snapshot, generator] dtype: bool Link_ramp_limit_up: - description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, link] + description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] Link_ramp_limit_down: - description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, link] + description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] Link_p_nom: description: nominal power dims: [scenario, link] @@ -384,11 +384,11 @@ parameters: description: cost of the square of one unit of internal power dims: [scenario, snapshot, process] Process_ramp_limit_up: - description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, process] + description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] Process_ramp_limit_down: - description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit - dims: [scenario, process] + description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] Process_p_set: description: a given internal power schedule; a process without one has no row here dims: [scenario, snapshot, process] @@ -1614,7 +1614,7 @@ expressions: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, generator] + dims: [scenario, snapshot, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 @@ -1622,7 +1622,7 @@ expressions: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, generator] + dims: [scenario, snapshot, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 @@ -1705,7 +1705,7 @@ expressions: description: >- the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, link] + dims: [scenario, snapshot, link] cases: given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } otherwise: 1 @@ -1713,7 +1713,7 @@ expressions: description: >- the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, link] + dims: [scenario, snapshot, link] cases: given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } otherwise: 1 @@ -1796,7 +1796,7 @@ expressions: description: >- the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [scenario, process] + dims: [scenario, snapshot, process] cases: given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } otherwise: 1 @@ -1804,7 +1804,7 @@ expressions: description: >- the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [scenario, process] + dims: [scenario, snapshot, process] cases: given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } otherwise: 1 diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 97342eaa..bd284b9d 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1702,5 +1702,31 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_45_ramp_per_snapshot": { + "columns": { + "Generator-p": 16, + "Link-p": 8, + "Process-p": 4 + }, + "global_constraints": {}, + "objective": 10996.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Generator-p-ramp_limit_down": 2, + "Generator-p-ramp_limit_up": 3, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8, + "Link-p-ramp_limit_down": 2, + "Link-p-ramp_limit_up": 3, + "Process-fix-p-lower": 4, + "Process-fix-p-upper": 4, + "Process-p-ramp_limit_down": 2, + "Process-p-ramp_limit_up": 3 + } } } diff --git a/examples/references/pypsa/rung_45_ramp_per_snapshot.py b/examples/references/pypsa/rung_45_ramp_per_snapshot.py new file mode 100644 index 00000000..dd15877a --- /dev/null +++ b/examples/references/pypsa/rung_45_ramp_per_snapshot.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 45: ramp limits per snapshot — a generator, a link and a process whose ramp limits change over time, and lift at one snapshot.""" + +from __future__ import annotations + +import math + +import spine + + +def build(): + """The spine plus a steep bus served by a generator, a link and a process with ramp limits per snapshot, with a dear backup.""" + n = spine.build() + n.add('Bus', 'steep') + up = [0.1, 0.1, 0.5, 0.1] + down = [0.1, 0.1, 0.1, math.nan] + n.add('Generator', 'steep_gen', bus='steep', p_nom=60, marginal_cost=3, ramp_limit_up=up, ramp_limit_down=down) + n.add( + 'Link', + 'steep_link', + bus0='north', + bus1='steep', + p_nom=40, + marginal_cost=4, + ramp_limit_up=up, + ramp_limit_down=down, + ) + n.add( + 'Process', + 'steep_proc', + bus0='south', + bus1='steep', + rate0=-1.25, + p_nom=40, + marginal_cost=5, + ramp_limit_up=up, + ramp_limit_down=down, + ) + n.add('Generator', 'steep_backup', bus='steep', p_nom=200, marginal_cost=500) + n.add('Load', 'steep_load', bus='steep', p_set=[10, 20, 90, 10]) + return n diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index fd3c36c8..6952a957 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -551,12 +551,14 @@ parameters: - global_constraint - generator Generator_ramp_limit_down: + - snapshot - generator Generator_ramp_limit_shut_down: - generator Generator_ramp_limit_start_up: - generator Generator_ramp_limit_up: + - snapshot - generator Generator_shut_down_cost: - generator @@ -674,12 +676,14 @@ parameters: - snapshot - link Link_ramp_limit_down: + - snapshot - link Link_ramp_limit_shut_down: - link Link_ramp_limit_start_up: - link Link_ramp_limit_up: + - snapshot - link Link_shut_down_cost: - link @@ -753,12 +757,14 @@ parameters: - snapshot - process Process_ramp_limit_down: + - snapshot - process Process_ramp_limit_shut_down: - process Process_ramp_limit_start_up: - process Process_ramp_limit_up: + - snapshot - process Process_rate: - process_output From d5b4e7590a71da900f43ed9f897d29262e8f4c0a Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 08:06:12 +0200 Subject: [PATCH 35/75] docs(pypsa): a unit that came in running ramps from its p_init into the first snapshot --- docs/examples/pypsa.md | 314 ++++++++++++++---- examples/pypsa.yaml | 141 +++++--- examples/references/pypsa/references.json | 50 +++ .../pypsa/rung_46_initial_output.py | 60 ++++ examples/symbols/pypsa.yaml | 3 + 5 files changed, 460 insertions(+), 108 deletions(-) create mode 100644 examples/references/pypsa/rung_46_initial_output.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 828c800d..36e23bc8 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -378,6 +378,7 @@ def build(): | ------------------------------ | ------ | ---------------------------------------------------------- | | [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) | done | the build, the allowance and the output carried in are cased quantities, so fixed, extendable and committed are one block; big-M is rung 8's. A missing limit reads as the full build, and a start-up or shut-down ramp alone builds the row, rung 28 | | [`{c}-p-ramp_limit_up/down`](#generator-p-ramp_limit_up) with a limit per snapshot | done | rung 45 | +| [`{c}-p-ramp_limit_*`](#generator-p-ramp_limit_up), `-bigM`, at the first snapshot from `p_init` | done | rung 46 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `8785.0`, 64 rows. @@ -3722,6 +3723,117 @@ def build(): +### Rung 46 — the output brought in + +`n.optimize()` with units that carry `p_init`, the output they brought into the +horizon. PyPSA reads `p_init` only where a unit came in running +(`up_time_before > 0`), and reads zero where it came in off +(`constraints.py:1091-1092`). It builds the ramp rows at the first snapshot +where that value exists (`constraints.py:1094`), so a unit that came in running +without `p_init` has none there, as before. It carries the value into the first +snapshot's rows with the status the unit came in with (`constraints.py:1101-1106`). +This is the same for a Generator, a Link and a Process. It holds for a fixed, +an extendable and a committable build, and for the big-M rows of a committable +extendable build (`constraints.py:937-946`). PyPSA reads `p_init` per scenario. +Under `multi_investment_periods` it reads it only at the horizon's first +snapshot, because no ramp row stands at a later period start +(`constraints.py:1097-1099`). The file states `{c}_p_init`, and the output +carried in at the first snapshot is `{c}_status_initial * {c}_p_init`. The +first-snapshot `where:` reads `{c}_status_initial == 0 OR {c}_p_init`. A +plain run feeds no `p_init`, and the rows collapse to the standard ones. + +PyPSA warns where a committable generator came in off and has a `p_init`, and +ignores the value (`consistency.py:669-680`). The product with +`{c}_status_initial` ignores it too. A unit that is not committable and came in +off is refused: see [Refusals](#refusals). PyPSA sets the first-snapshot mask +without its `active` mask, so a unit that does not stand at the first snapshot +still gets a row there, with no variable in it. The file does not state that +row. + +The rung adds a warm bus with a load of `150`, then `60`, served by six +ramp-limited units: a fixed generator, a link from `p_init = 0`, a dear process +that came in at full output, an extendable generator, a committable generator +and a committable extendable generator. Each `p_init` binds. Without any of +them, PyPSA solves to `9921.0` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-p-ramp_limit_*`](#generator-p-ramp_limit_up), [`-bigM`](#generator-p-ramp_limit_up-run-bigm), at the first snapshot | done | `{c}_previous_p` opens on `{c}_status_initial * {c}_p_init`; the row stands where `{c}_status_initial == 0 OR {c}_p_init` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `101294.125`, 200 rows. + +
+The network, as PyPSA code + +`rung_46_initial_output.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 46: the output brought in — units that came in running with a given `p_init` ramp from it into the first snapshot.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a warm bus served by fixed, extendable and committable units that each carry a `p_init`, with a dear backup.""" + n = spine.build() + n.add('Bus', 'warm') + ramps = {'ramp_limit_up': 0.25, 'ramp_limit_down': 0.25} + n.add('Generator', 'warm_gen', bus='warm', p_nom=60, marginal_cost=3, p_init=10, **ramps) + n.add('Link', 'warm_link', bus0='north', bus1='warm', p_nom=40, marginal_cost=4, p_init=0, **ramps) + n.add( + 'Process', 'warm_proc', bus0='south', bus1='warm', rate0=-1.25, p_nom=40, marginal_cost=600, p_init=40, **ramps + ) + n.add( + 'Generator', + 'warm_ext', + bus='warm', + p_nom_extendable=True, + p_nom_max=40, + capital_cost=5, + marginal_cost=2, + p_init=5, + **ramps, + ) + n.add( + 'Generator', + 'warm_com', + bus='warm', + committable=True, + p_nom=50, + p_min_pu=0.2, + marginal_cost=2.5, + p_init=30, + ramp_limit_start_up=0.4, + ramp_limit_shut_down=0.4, + **ramps, + ) + n.add( + 'Generator', + 'warm_com_ext', + bus='warm', + committable=True, + p_nom_extendable=True, + p_nom_max=40, + capital_cost=5, + marginal_cost=2.2, + p_init=5, + **ramps, + ) + n.add('Generator', 'warm_backup', bus='warm', p_nom=300, marginal_cost=500) + n.add('Load', 'warm_load', bus='warm', p_set=[150, 150, 60, 60]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -3745,6 +3857,7 @@ where it should live — language, data prep, or harness — is one open questio | `NotImplementedError`, `global_constraints.py:66-68` | a `tech_capacity_expansion_limit` row on a network with scenarios | assumed where there is more than one scenario: [`GlobalConstraint_tech_capacity_expansion_limit_without_scenarios`](#globalconstraint_tech_capacity_expansion_limit_without_scenarios). The file cannot tell one scenario from none, which PyPSA also refuses | | | `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | assumed: [`Generator_maintenance_events_positive`](#generator_maintenance_events_positive), [`-duration_positive`](#generator_maintenance_duration_positive), [`-duration_fits_the_horizon`](#generator_maintenance_duration_fits_the_horizon), [`-events_fit_the_horizon`](#generator_maintenance_events_fit_the_horizon), [`-build_cap_is_finite`](#generator_maintenance_build_cap_is_finite), and the `Link` and `Process` ones | | | nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | assumed: [`Generator_maintenance_module_count_is_finite`](#generator_maintenance_module_count_is_finite), and the `Link` and `Process` ones | | +| nothing; PyPSA builds the row, `constraints.py:1091-1094`, `1110-1112` | a ramp-limited Generator, Link or Process that is not committable, with `up_time_before = 0` | assumed: [`Generator_came_in_running_unless_committable`](#generator_came_in_running_unless_committable), and the `Link` and `Process` ones. PyPSA caps the unit at zero in the first snapshot, or at its start-up ramp where another unit of the component is committable with a fixed build, and documents `up_time_before` as read only for a committable unit | | Duals and solutions are read back by the harness on the lpspec side: `marginal_price` is the balance dual over `w_objective`, `mu_upper` the @@ -3799,6 +3912,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\Xi \times \mathcal{G}`$ — cost of one start | @@ -3830,6 +3944,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays on once started | | $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays off once stopped | | $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$ — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | | $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\Xi \times \mathcal{L}`$ — cost of one start | @@ -3866,6 +3981,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays on once started | | $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays off once stopped | | $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$ — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | | $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\Xi \times \mathcal{J}`$ — cost of one start | @@ -4083,7 +4199,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | Symbol | Meaning | |---|---| | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | -| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | +| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | | $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\Xi \times \mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | | $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | @@ -4094,7 +4210,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\Xi \times \mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | -| $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — nothing at the start of the horizon, which is why a link that came in running carries no ramp row there | +| $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | | $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | | $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\Xi \times \mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | @@ -4104,7 +4220,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\Xi \times \mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | | $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | -| $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — nothing at the start of the horizon, which is why a process that came in running carries no ramp row there | +| $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | | $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | | $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\Xi \times \mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | @@ -4786,7 +4902,7 @@ Generator_p_ramp_limit_up_run_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_p - Generator_previous_p <= @@ -4795,7 +4911,7 @@ Generator_p_ramp_limit_up_run_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_up-start-bigM` @@ -4812,7 +4928,7 @@ Generator_p_ramp_limit_up_start_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_p - Generator_previous_p <= @@ -4821,7 +4937,7 @@ Generator_p_ramp_limit_up_start_big_m: ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-run-bigM` @@ -4838,7 +4954,7 @@ Generator_p_ramp_limit_down_run_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_previous_p - Generator_p <= @@ -4847,7 +4963,7 @@ Generator_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down-shut-bigM` @@ -4864,7 +4980,7 @@ Generator_p_ramp_limit_down_shut_big_m: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_previous_p - Generator_p <= @@ -4873,7 +4989,7 @@ Generator_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p_nom_modularity` @@ -5474,7 +5590,7 @@ Link_p_ramp_limit_up_run_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_p - Link_previous_p <= @@ -5483,7 +5599,7 @@ Link_p_ramp_limit_up_run_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_up-start-bigM` @@ -5500,7 +5616,7 @@ Link_p_ramp_limit_up_start_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_p - Link_previous_p <= @@ -5509,7 +5625,7 @@ Link_p_ramp_limit_up_start_big_m: ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-run-bigM` @@ -5526,7 +5642,7 @@ Link_p_ramp_limit_down_run_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_previous_p - Link_p <= @@ -5535,7 +5651,7 @@ Link_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down-shut-bigM` @@ -5552,7 +5668,7 @@ Link_p_ramp_limit_down_shut_big_m: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_previous_p - Link_p <= @@ -5561,7 +5677,7 @@ Link_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p_nom_modularity` @@ -6162,7 +6278,7 @@ Process_p_ramp_limit_up_run_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_p - Process_previous_p <= @@ -6171,7 +6287,7 @@ Process_p_ramp_limit_up_run_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_up-start-bigM` @@ -6188,7 +6304,7 @@ Process_p_ramp_limit_up_start_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_p - Process_previous_p <= @@ -6197,7 +6313,7 @@ Process_p_ramp_limit_up_start_big_m: ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-run-bigM` @@ -6214,7 +6330,7 @@ Process_p_ramp_limit_down_run_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_previous_p - Process_p <= @@ -6223,7 +6339,7 @@ Process_p_ramp_limit_down_run_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down-shut-bigM` @@ -6240,7 +6356,7 @@ Process_p_ramp_limit_down_shut_big_m: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_previous_p - Process_p <= @@ -6249,7 +6365,7 @@ Process_p_ramp_limit_down_shut_big_m: ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p_nom_modularity` @@ -7272,19 +7388,20 @@ Generator_p_ramp_limit_up: `Generator-p-ramp_limit_up` — a generator raises output no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance ``` ```math -p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Generator-p-ramp_limit_down` @@ -7297,19 +7414,20 @@ Generator_p_ramp_limit_down: `Generator-p-ramp_limit_down` — a generator lowers output no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance ``` ```math -\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}_{g} \wedge \mathrm{u}^{0}_{\xi,g} = 0 \right) \wedge \mathrm{on}_{t,g} +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} ``` ### `Link-p-ramp_limit_up` @@ -7322,19 +7440,20 @@ Link_p_ramp_limit_up: `Link-p-ramp_limit_up` — a link raises flow no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A link that came into the - horizon running brought an unknown flow, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance ``` ```math -f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Link-p-ramp_limit_down` @@ -7347,19 +7466,20 @@ Link_p_ramp_limit_down: `Link-p-ramp_limit_down` — a link lowers flow no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A link that came into the - horizon running brought an unknown flow, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance ``` ```math -\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{u}^{f,0}_{\xi,l} = 0 \right) \wedge \mathrm{on}^{f}_{t,l} +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} ``` ### `Process-p-ramp_limit_up` @@ -7372,19 +7492,20 @@ Process_p_ramp_limit_up: `Process-p-ramp_limit_up` — a process raises internal power no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A process that came into the - horizon running brought an unknown internal power, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance ``` ```math -z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `Process-p-ramp_limit_down` @@ -7397,19 +7518,20 @@ Process_p_ramp_limit_down: `Process-p-ramp_limit_down` — a process lowers internal power no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A process that came into the - horizon running brought an unknown internal power, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance ``` ```math -\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{u}^{z,0}_{\xi,j} = 0 \right) \wedge \mathrm{on}^{z}_{t,j} +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} ``` ### `StorageUnit-ext-p_dispatch-lower` @@ -8214,17 +8336,17 @@ Generator_previous_status: ```yaml Generator_previous_p: description: >- - the output a generator carries into a snapshot — nothing at the start of - the horizon, which is why a unit that came in running carries no ramp row - there + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, generator] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } otherwise: shift(Generator_p, along=snapshot, offset=1) ``` ```math -\overleftarrow{p}_{\xi,t,g} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ p_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +\overleftarrow{p}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{\xi,g} \cdot \mathrm{p}^{0}_{\xi,g} & \text{if } \mathrm{pos}(t) = 0 \\ p_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_p_nom_effective` @@ -8410,17 +8532,17 @@ Link_previous_status: ```yaml Link_previous_p: description: >- - the flow a link carries into a snapshot — nothing at the start of - the horizon, which is why a link that came in running carries no ramp row - there + the flow a link carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, link] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Link_status_initial * Link_p_init } otherwise: shift(Link_p, along=snapshot, offset=1) ``` ```math -\overleftarrow{f}_{\xi,t,l} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ f_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +\overleftarrow{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{\xi,l} \cdot \mathrm{f}^{0}_{\xi,l} & \text{if } \mathrm{pos}(t) = 0 \\ f_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} ``` ### `Link_ramp_up_rate` @@ -8591,17 +8713,17 @@ Process_previous_status: ```yaml Process_previous_p: description: >- - the internal power a process carries into a snapshot — nothing at the start of - the horizon, which is why a process that came in running carries no ramp row - there + the internal power a process carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, process] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Process_status_initial * Process_p_init } otherwise: shift(Process_p, along=snapshot, offset=1) ``` ```math -\overleftarrow{z}_{\xi,t,j} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ z_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +\overleftarrow{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{\xi,j} \cdot \mathrm{z}^{0}_{\xi,j} & \text{if } \mathrm{pos}(t) = 0 \\ z_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} ``` ### `Process_ramp_up_rate` @@ -10025,6 +10147,66 @@ GlobalConstraint_tech_capacity_expansion_limit_without_scenarios: ```math \mathrm{type}_{i} \neq \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \qquad \forall\, i \in \mathcal{I} \,:\, \lvert \{ \xi \in \Xi \,:\, \pi_{\xi} \text{ is defined} \} \rvert > 1 ``` + +### `Generator_came_in_running_unless_committable` + +```yaml +Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. The spec does not state that row, so it refuses the data +``` + +```math +\mathrm{u}^{0}_{\xi,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}_{\xi,t,g} \text{ is defined} \right) +``` + +### `Link_came_in_running_unless_committable` + +```yaml +Link_came_in_running_unless_committable: + holds: "Link_status_initial == 1" + where: "NOT Link_committable AND (Link_ramp_limit_up OR Link_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a link that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the link there at zero, or at its + start-up ramp where another link of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable link and does not check + it. The spec does not state that row, so it refuses the data +``` + +```math +\mathrm{u}^{f,0}_{\xi,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{com}^{f}_{l} \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \right) +``` + +### `Process_came_in_running_unless_committable` + +```yaml +Process_came_in_running_unless_committable: + holds: "Process_status_initial == 1" + where: "NOT Process_committable AND (Process_ramp_limit_up OR Process_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a process that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the process there at zero, or at its + start-up ramp where another process of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable process and does not check + it. The spec does not state that row, so it refuses the data +``` + +```math +\mathrm{u}^{z,0}_{\xi,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{com}^{z}_{j} \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \right) +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index dc64f276..70cbeee9 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -129,6 +129,12 @@ parameters: description: one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep dims: [scenario, generator] dtype: int + Generator_p_init: + description: >- + the output a unit brought into the horizon — PyPSA's `p_init`, read + only where the unit came in running; no value means it is unknown, so + the unit carries no ramp row at the first snapshot + dims: [scenario, generator] Generator_must_stay_up: description: >- true while the up time a unit brought into the horizon still binds — @@ -270,6 +276,12 @@ parameters: description: one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep dims: [scenario, link] dtype: int + Link_p_init: + description: >- + the flow a link brought into the horizon — PyPSA's `p_init`, read + only where the link came in running; no value means it is unknown, so + the link carries no ramp row at the first snapshot + dims: [scenario, link] Link_must_stay_up: description: >- true while the up time a link brought into the horizon still binds — @@ -426,6 +438,12 @@ parameters: description: one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep dims: [scenario, process] dtype: int + Process_p_init: + description: >- + the internal power a process brought into the horizon — PyPSA's `p_init`, read + only where the process came in running; no value means it is unknown, so + the process carries no ramp row at the first snapshot + dims: [scenario, process] Process_must_stay_up: description: >- true while the up time a process brought into the horizon still binds — @@ -1597,12 +1615,12 @@ expressions: otherwise: shift(Generator_status, along=snapshot, offset=1) Generator_previous_p: description: >- - the output a generator carries into a snapshot — nothing at the start of - the horizon, which is why a unit that came in running carries no ramp row - there + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, generator] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } otherwise: shift(Generator_p, along=snapshot, offset=1) Generator_p_nom_effective: description: the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise @@ -1694,12 +1712,12 @@ expressions: otherwise: shift(Link_status, along=snapshot, offset=1) Link_previous_p: description: >- - the flow a link carries into a snapshot — nothing at the start of - the horizon, which is why a link that came in running carries no ramp row - there + the flow a link carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, link] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Link_status_initial * Link_p_init } otherwise: shift(Link_p, along=snapshot, offset=1) Link_ramp_up_rate: description: >- @@ -1785,12 +1803,12 @@ expressions: otherwise: shift(Process_status, along=snapshot, offset=1) Process_previous_p: description: >- - the internal power a process carries into a snapshot — nothing at the start of - the horizon, which is why a process that came in running carries no ramp row - there + the internal power a process carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [scenario, snapshot, process] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Process_status_initial * Process_p_init } otherwise: shift(Process_p, along=snapshot, offset=1) Process_ramp_up_rate: description: >- @@ -2318,7 +2336,7 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_p - Generator_previous_p <= @@ -2333,7 +2351,7 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_p - Generator_previous_p <= @@ -2348,7 +2366,7 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_previous_p - Generator_p <= @@ -2363,7 +2381,7 @@ constraints: where: >- Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Generator_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: >- Generator_previous_p - Generator_p <= @@ -2588,7 +2606,7 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_p - Link_previous_p <= @@ -2603,7 +2621,7 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_p - Link_previous_p <= @@ -2618,7 +2636,7 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_previous_p - Link_p <= @@ -2633,7 +2651,7 @@ constraints: where: >- Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Link_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: >- Link_previous_p - Link_p <= @@ -2858,7 +2876,7 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_p - Process_previous_p <= @@ -2873,7 +2891,7 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_p - Process_previous_p <= @@ -2888,7 +2906,7 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_previous_p - Process_p <= @@ -2903,7 +2921,7 @@ constraints: where: >- Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND Process_status_initial == 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: >- Process_previous_p - Process_p <= @@ -3297,13 +3315,14 @@ constraints: `Generator-p-ramp_limit_up` — a generator raises output no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance Generator_p_ramp_limit_down: @@ -3311,13 +3330,14 @@ constraints: `Generator-p-ramp_limit_down` — a generator lowers output no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any unit a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead dims: [scenario, snapshot, generator] where: >- (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_committable AND Generator_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) AND Generator_active expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Link_p_ramp_limit_up: @@ -3325,13 +3345,14 @@ constraints: `Link-p-ramp_limit_up` — a link raises flow no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A link that came into the - horizon running brought an unknown flow, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_up OR Link_ramp_limit_start_up) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: Link_p - Link_previous_p <= Link_ramp_up_allowance Link_p_ramp_limit_down: @@ -3339,13 +3360,14 @@ constraints: `Link-p-ramp_limit_down` — a link lowers flow no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A link that came into the - horizon running brought an unknown flow, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any link a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead dims: [scenario, snapshot, link] where: >- (Link_ramp_limit_down OR Link_ramp_limit_shut_down) AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_committable AND Link_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) AND Link_active expression: Link_previous_p - Link_p <= Link_ramp_down_allowance Process_p_ramp_limit_up: @@ -3353,13 +3375,14 @@ constraints: `Process-p-ramp_limit_up` — a process raises internal power no faster than its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A process that came into the - horizon running brought an unknown internal power, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_up OR Process_ramp_limit_start_up) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: Process_p - Process_previous_p <= Process_ramp_up_allowance Process_p_ramp_limit_down: @@ -3367,13 +3390,14 @@ constraints: `Process-p-ramp_limit_down` — a process lowers internal power no faster than its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A process that came into the - horizon running brought an unknown internal power, so it carries no row at the - first snapshot, nor at the start of a later investment period — nor does any process a big M releases instead + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead dims: [scenario, snapshot, process] where: >- (Process_ramp_limit_down OR Process_ramp_limit_shut_down) AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) - AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_committable AND Process_status_initial == 0))) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) AND Process_active expression: Process_previous_p - Process_p <= Process_ramp_down_allowance StorageUnit_ext_p_dispatch_lower: @@ -3906,6 +3930,39 @@ assumptions: (`global_constraints.py:66-68`). The spec cannot tell a network with one scenario from one with none, so it refuses only where there is more than one scenario + Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. The spec does not state that row, so it refuses the data + Link_came_in_running_unless_committable: + holds: "Link_status_initial == 1" + where: "NOT Link_committable AND (Link_ramp_limit_up OR Link_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a link that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the link there at zero, or at its + start-up ramp where another link of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable link and does not check + it. The spec does not state that row, so it refuses the data + Process_came_in_running_unless_committable: + holds: "Process_status_initial == 1" + where: "NOT Process_committable AND (Process_ramp_limit_up OR Process_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a process that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the process there at zero, or at its + start-up ramp where another process of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable process and does not check + it. The spec does not state that row, so it refuses the data objective: sense: minimize diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index bd284b9d..a057da29 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1728,5 +1728,55 @@ "Process-p-ramp_limit_down": 2, "Process-p-ramp_limit_up": 3 } + }, + "rung_46_initial_output": { + "columns": { + "Generator-p": 28, + "Generator-p_nom": 2, + "Generator-shut_down": 8, + "Generator-start_up": 8, + "Generator-status": 8, + "Link-p": 8, + "Process-p": 4 + }, + "global_constraints": {}, + "objective": 101294.125, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-com-ext-p-lower": 4, + "Generator-com-ext-p-lower-nonneg": 4, + "Generator-com-ext-p-upper-bigM": 4, + "Generator-com-ext-p-upper-cap": 4, + "Generator-com-p-lower": 4, + "Generator-com-p-upper": 4, + "Generator-com-status-min_up_time_must_stay_up": 0, + "Generator-com-transition-shut-down": 8, + "Generator-com-transition-start-up": 8, + "Generator-ext-p-lower": 4, + "Generator-ext-p-upper": 4, + "Generator-ext-p_nom-lower": 2, + "Generator-ext-p_nom-upper": 2, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Generator-p-ramp_limit_down": 12, + "Generator-p-ramp_limit_down-run-bigM": 4, + "Generator-p-ramp_limit_down-shut-bigM": 4, + "Generator-p-ramp_limit_up": 12, + "Generator-p-ramp_limit_up-run-bigM": 4, + "Generator-p-ramp_limit_up-start-bigM": 4, + "Generator-shut_down-p-fixed-upper": 8, + "Generator-start_up-p-fixed-upper": 8, + "Generator-status-p-fixed-upper": 8, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8, + "Link-p-ramp_limit_down": 4, + "Link-p-ramp_limit_up": 4, + "Process-fix-p-lower": 4, + "Process-fix-p-upper": 4, + "Process-p-ramp_limit_down": 4, + "Process-p-ramp_limit_up": 4 + } } } diff --git a/examples/references/pypsa/rung_46_initial_output.py b/examples/references/pypsa/rung_46_initial_output.py new file mode 100644 index 00000000..737e2b04 --- /dev/null +++ b/examples/references/pypsa/rung_46_initial_output.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 46: the output brought in — units that came in running with a given `p_init` ramp from it into the first snapshot.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus a warm bus served by fixed, extendable and committable units that each carry a `p_init`, with a dear backup.""" + n = spine.build() + n.add('Bus', 'warm') + ramps = {'ramp_limit_up': 0.25, 'ramp_limit_down': 0.25} + n.add('Generator', 'warm_gen', bus='warm', p_nom=60, marginal_cost=3, p_init=10, **ramps) + n.add('Link', 'warm_link', bus0='north', bus1='warm', p_nom=40, marginal_cost=4, p_init=0, **ramps) + n.add( + 'Process', 'warm_proc', bus0='south', bus1='warm', rate0=-1.25, p_nom=40, marginal_cost=600, p_init=40, **ramps + ) + n.add( + 'Generator', + 'warm_ext', + bus='warm', + p_nom_extendable=True, + p_nom_max=40, + capital_cost=5, + marginal_cost=2, + p_init=5, + **ramps, + ) + n.add( + 'Generator', + 'warm_com', + bus='warm', + committable=True, + p_nom=50, + p_min_pu=0.2, + marginal_cost=2.5, + p_init=30, + ramp_limit_start_up=0.4, + ramp_limit_shut_down=0.4, + **ramps, + ) + n.add( + 'Generator', + 'warm_com_ext', + bus='warm', + committable=True, + p_nom_extendable=True, + p_nom_max=40, + capital_cost=5, + marginal_cost=2.2, + p_init=5, + **ramps, + ) + n.add('Generator', 'warm_backup', bus='warm', p_nom=300, marginal_cost=500) + n.add('Load', 'warm_load', bus='warm', p_set=[150, 150, 60, 60]) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 4ed2b4fc..621c9fd4 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -44,6 +44,7 @@ names: Generator_min_up_time: '\mathrm{UT}' Generator_min_down_time: '\mathrm{DT}' Generator_status_initial: '\mathrm{u}^{0}' + Generator_p_init: '\mathrm{p}^{0}' Generator_previous_status: '\overleftarrow{u}' Generator_previous_p: '\overleftarrow{p}' Generator_ramp_up_allowance: '\Delta^{+}' @@ -84,6 +85,7 @@ names: Link_min_up_time: '\mathrm{UT}^{f}' Link_min_down_time: '\mathrm{DT}^{f}' Link_status_initial: '\mathrm{u}^{f,0}' + Link_p_init: '\mathrm{f}^{0}' Link_previous_status: '\overleftarrow{u}^{f}' Link_previous_p: '\overleftarrow{f}' Link_ramp_up_allowance: '\Delta^{f,+}' @@ -153,6 +155,7 @@ names: Process_min_up_time: '\mathrm{UT}^{z}' Process_min_down_time: '\mathrm{DT}^{z}' Process_status_initial: '\mathrm{u}^{z,0}' + Process_p_init: '\mathrm{z}^{0}' Process_previous_status: '\overleftarrow{u}^{z}' Process_previous_p: '\overleftarrow{z}' Process_ramp_up_allowance: '\Delta^{z,+}' From 9998ede5ce0e5efe0d0cd7f252c091667c9dcb8e Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 08:28:26 +0200 Subject: [PATCH 36/75] docs(pypsa): the relaxed commitment file ramps every generator per snapshot and from its p_init, tightens at the full build and signs its balance --- docs/examples/pypsa.md | 2 +- docs/examples/pypsa_linearized_uc.md | 303 ++++++++++++++---- examples/pypsa_linearized_uc.yaml | 140 +++++--- examples/references/pypsa/references.json | 34 ++ .../pypsa/rung_47_linearized_ramps.py | 64 ++++ examples/symbols/pypsa_linearized_uc.yaml | 5 + 6 files changed, 445 insertions(+), 103 deletions(-) create mode 100644 examples/references/pypsa/rung_47_linearized_ramps.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 36e23bc8..02ee344e 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -608,7 +608,7 @@ def build(): | [`{c}-com-status-min_up_time_must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | | [`{c}-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | the same prep mask over the down time brought in, status zero; PyPSA's name says `_must_stay_up`; rung 24 records it | | [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | -| [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rung 12, a file of its own | +| [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rungs 12, 44 and 47, a file of its own: Generator commitment on fixed builds | > ✔ `pypsa 1.3.0` solves this rung's network at objective `7775.0`, 116 rows. diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 634322e5..64e7da22 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -5,9 +5,15 @@ SPDX-License-Identifier: CC-BY-4.0 # PyPSA, the relaxed commitment -Rungs 12 and 44 of [PyPSA in one file](pypsa.md): `n.optimize(linearized_unit_commitment=True)`, stated on rungs 1 and 7 +Rungs 12, 44 and 47 of [PyPSA in one file](pypsa.md): `n.optimize(linearized_unit_commitment=True)`, stated on rungs 1 and 7 in a file of its own. Each network is the spine plus the script's own additions. +The file covers generators, links and loads with a fixed build, in one +scenario, with every asset active in every snapshot. Only a generator is +committable. A committable link or process, an extendable build, scenarios and +`active` are out of this file's scope. [PyPSA in one file](pypsa.md) states +them for the integer run, and the keyword relaxes them in the same way. + ## Rung 12 — linearized unit commitment | PyPSA | status | note | @@ -122,8 +128,6 @@ maintainable, to `7310.0`; with the fixed unit not maintainable, to `7100.0`. | [`Generator-p-ramp_limit_up`](#generator-p-ramp_limit_up), [`-down`](#generator-p-ramp_limit_down) for a start-up or shut-down ramp alone, and a missing limit | done | `Generator_ramp_up_rate` and the other three rates read a missing limit as `1` | | [`Generator-maint-*`](#generator-maint-event-count), [`Generator-maint-status-*`](#generator-maint-status-le-status) | done | fixed units only, as the file states no extendable build | | [`Generator-com-p-lower`](#generator-com-p-lower), [`-upper`](#generator-com-p-upper), [`Generator-fix-p-lower`](#generator-fix-p-lower), [`-upper`](#generator-fix-p-upper) in maintenance | done | | -| [`Generator-com-p-before`](#generator-com-p-before), [`-current`](#generator-com-p-current), [`-partly-start-up`](#generator-com-partly-start-up), [`-partly-shut-down`](#generator-com-partly-shut-down) with a missing ramp limit | out | PyPSA reads a missing limit as the full build (`constraints.py:311-316`); the file reads it as `0` | -| a ramp row for a unit that is not committable | out | PyPSA builds it with the status fixed at `1` (`constraints.py:1073-1079`); the file states ramp rows for committable units only | > ✔ `pypsa 1.3.0` solves this rung's network at objective `7400.0`, 191 rows. @@ -194,10 +198,129 @@ def build(): +## Rung 47 — ramps and signs, as the integer file states them + +This rung brings five rows of [PyPSA in one file](pypsa.md) into this file, +each on the fixed builds of this file's surface: + +- The four tightening rows read each ramp limit filled to the full build where + it is missing (`constraints.py:313-318`). They read `Generator_ramp_up_rate` + and the other three rates. The earlier file read a missing limit as `0`. +- A ramp limit is given per snapshot and read at the later of the two + snapshots (`constraints.py:1040-1041`). A row is absent at a snapshot where + neither the limit nor the start-up or shut-down ramp has a value + (`constraints.py:1046-1047`, `1136`, `1154`). +- A unit that came in running with a `p_init` ramps from it into the first + snapshot (`constraints.py:1091-1094`, `1102-1104`). Where `p_init` has no + value, the unit carries no ramp row at the first snapshot. +- A unit that is not committable carries ramp rows with its status fixed at + `1` (`constraints.py:1073-1079`). The assumption + `Generator_came_in_running_unless_committable` refuses such a unit that came + in off, as the integer file does. +- Each generator and load term enters the bus balance with its component's + `sign` (`constraints.py:1428-1429`, `:1538`). + +The rung adds a relax bus with a tightened unit that has only start-up and +shut-down ramps, a committable unit whose ramp limit lifts at the third +snapshot, a dear committable unit that came in running at `p_init=40`, a fixed +unit with ramp limits, a unit of sign `-1`, a load of sign `1` and a dear +backup. Each binds: PyPSA solves to `12862.5`. With the missing limits read as +`0` in the two partly rows, it solves to `14085.64`. With the ramp limit at +`0.25` in every snapshot, it solves to `14554.75`. Without the `p_init`, it +solves to `8437.5`. Without the fixed unit's ramp limits, it solves to +`12800.0`. With the unit's sign at `1`, it solves to `12592.5`. With the load's +sign at `-1`, it solves to `13887.5`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Generator-com-p-before`](#generator-com-p-before), [`-current`](#generator-com-p-current), [`-partly-start-up`](#generator-com-partly-start-up), [`-partly-shut-down`](#generator-com-partly-shut-down) with a missing ramp limit | done | the four rates read a missing limit as `1` | +| [`Generator-p-ramp_limit_up`](#generator-p-ramp_limit_up), [`-down`](#generator-p-ramp_limit_down) per snapshot | done | `Generator_ramp_limit_up` and `-down` over `[snapshot, generator]` | +| [`Generator-p-ramp_limit_up`](#generator-p-ramp_limit_up), [`-down`](#generator-p-ramp_limit_down) at the first snapshot from `p_init` | done | `Generator_previous_p` opens at `Generator_status_initial * Generator_p_init` | +| [`Generator-p-ramp_limit_up`](#generator-p-ramp_limit_up), [`-down`](#generator-p-ramp_limit_down) for a unit that is not committable | done | `Generator_ramp_up_allowance` and `-down` read the status as `1` | +| [`Bus-nodal_balance`](#bus-nodal_balance) with `sign` | done | `Generator_sign` and `Load_sign` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `12862.5`, 179 rows. + +
+The network, as PyPSA code + +`rung_47_linearized_ramps.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 47: the relaxed file's ramps and signs — tightening at the full build, a ramp limit per snapshot, a `p_init`, a unit that is not committable, and a sign.""" + +from __future__ import annotations + +import math + +import spine + +MODEL = 'pypsa_linearized_uc.yaml' +OPTIMIZE = {'linearized_unit_commitment': True} + + +def build(): + """The spine plus a relax bus: five units that each carry one of the rows under review, a feeding load and a dear backup.""" + n = spine.build() + n.add('Bus', 'relax') + n.add( + 'Generator', + 'tight47', + bus='relax', + committable=True, + p_nom=50, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_start_up=0.5, + ramp_limit_shut_down=0.5, + start_up_cost=10, + shut_down_cost=10, + ) + n.add( + 'Generator', + 'steep47', + bus='relax', + committable=True, + p_nom=40, + marginal_cost=3, + start_up_cost=1, + ramp_limit_up=[0.25, 0.25, math.nan, 0.25], + ramp_limit_down=[0.25, 0.25, 0.25, math.nan], + ramp_limit_start_up=0.25, + ) + n.add( + 'Generator', + 'warm47', + bus='relax', + committable=True, + p_nom=40, + marginal_cost=50, + start_up_cost=1, + p_init=40, + ramp_limit_down=0.25, + ramp_limit_shut_down=0.5, + ) + n.add('Generator', 'fixed47', bus='relax', p_nom=60, marginal_cost=1, ramp_limit_up=0.25, ramp_limit_down=0.25) + n.add('Generator', 'sink47', bus='relax', p_nom=20, p_min_pu=0.5, sign=-1) + n.add('Generator', 'backup47', bus='relax', p_nom=300, marginal_cost=500) + n.add('Load', 'feed47', bus='relax', p_set=10, sign=1) + n.add('Load', 'swing47', bus='relax', p_set=[60, 60, 140, 40]) + return n +``` + +
+ + ## The file -The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, stated on rung 1's transport surface in a file of its own. The status, its starts and its stops are shares in \[0, 1\] rather than binaries — a domain is the model's, not the data's — and four rows PyPSA adds only under the keyword tighten the relaxation where a unit's start and stop cost the same. `examples/pypsa.yaml` stays the integer one. +The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, stated on rung 1's transport surface in a file of its own. The status, its starts and its stops are shares in \[0, 1\] rather than binaries — a domain is the model's, not the data's — and four rows PyPSA adds only under the keyword tighten the relaxation where a unit's start and stop cost the same. The surface is generators, links and loads with a fixed build, in one scenario, every asset active in every snapshot, and only a generator committable. `examples/pypsa.yaml` stays the integer one, and states the rest: a committable link or process, an extendable build, scenarios and `active`. #### Sets @@ -220,20 +343,23 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | | $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | +| $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power | | $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | | $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | | $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | +| $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | | $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\mathcal{G}`$ — cost of one start | @@ -264,11 +390,13 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | Symbol | Meaning | |---|---| | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | -| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | -| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | -| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | | $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | | $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | $`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. @@ -361,17 +489,18 @@ Bus_nodal_balance: description: >- `Bus-nodal_balance` — what is generated at a bus, less what the links take away, plus what arrives over them after losses, meets the load - there + there. Each generator and load term enters with its component's `sign` + (`constraints.py:1428-1429`, `:1538`) dims: [snapshot, bus] expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) + sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{t,d} \right) \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Generator-com-p-lower` @@ -517,24 +646,20 @@ u_{t,g} = 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm ```yaml Generator_p_ramp_limit_up: description: >- - `Generator-p-ramp_limit_up` — a committed unit raises output no faster - than its limit while it was already on, and no further than its + `Generator-p-ramp_limit_up` — a generator raises output no faster than + its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in dims: [snapshot, generator] where: >- - Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) - expression: >- - Generator_p - Generator_previous_p <= - Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status - + Generator_start_up_rate * Generator_p_nom - * (Generator_status - Generator_previous_status) + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot) > 0 OR Generator_status_initial == 0 OR Generator_p_init) + expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance ``` ```math -p_{t,g} - \overleftarrow{p}_{t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \overleftarrow{u}_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \left( \mathrm{ru}_{g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +p_{t,g} - \overleftarrow{p}_{t,g} \le \Delta^{+}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \vee \mathrm{p}^{0}_{g} \text{ is defined} \right) ``` ### `Generator-p-ramp_limit_down` @@ -544,24 +669,20 @@ p_{t,g} - \overleftarrow{p}_{t,g} \le \widetilde{\mathrm{ru}}_{g} \cdot \mathrm{ ```yaml Generator_p_ramp_limit_down: description: >- - `Generator-p-ramp_limit_down` — a committed unit lowers output no - faster than its limit while it stays on, and no further than its + `Generator-p-ramp_limit_down` — a generator lowers output no faster than + its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in dims: [snapshot, generator] where: >- - Generator_committable AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) - expression: >- - Generator_previous_p - Generator_p <= - Generator_ramp_down_rate * Generator_p_nom * Generator_status - + Generator_shut_down_rate * Generator_p_nom - * (Generator_previous_status - Generator_status) + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot) > 0 OR Generator_status_initial == 0 OR Generator_p_init) + expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance ``` ```math -\overleftarrow{p}_{t,g} - p_{t,g} \le \widetilde{\mathrm{rd}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{t,g} - u_{t,g} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \left( \mathrm{rd}_{g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \right) +\overleftarrow{p}_{t,g} - p_{t,g} \le \Delta^{-}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \right) \wedge \left( \mathrm{pos}(t) > 0 \vee \mathrm{u}^{0}_{g} = 0 \vee \mathrm{p}^{0}_{g} \text{ is defined} \right) ``` ### `Generator-status-p-fixed-upper` @@ -726,13 +847,13 @@ Generator_com_p_before: where: Generator_committable AND Generator_partly_tightened expression: >- shift(Generator_p, along=snapshot, offset=1) - - Generator_ramp_limit_shut_down * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - - (Generator_p_max_pu * Generator_p_nom - Generator_ramp_limit_shut_down * Generator_p_nom) + - Generator_shut_down_rate * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) + - (Generator_p_max_pu * Generator_p_nom - Generator_shut_down_rate * Generator_p_nom) * (Generator_status - Generator_start_up) <= 0 ``` ```math -p_{t - 1,g} - \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} - \left( \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \left( u_{t,g} - \mathit{up}_{t,g} \right) \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} +p_{t - 1,g} - \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} - \left( \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \left( u_{t,g} - \mathit{up}_{t,g} \right) \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} ``` ### `Generator-com-p-current` @@ -746,11 +867,11 @@ Generator_com_p_current: where: Generator_committable AND Generator_partly_tightened AND position(snapshot) > 0 expression: >- Generator_p - Generator_p_max_pu * Generator_p_nom * Generator_status - + (Generator_p_max_pu * Generator_p_nom - Generator_ramp_limit_start_up * Generator_p_nom) * Generator_start_up <= 0 + + (Generator_p_max_pu * Generator_p_nom - Generator_start_up_rate * Generator_p_nom) * Generator_start_up <= 0 ``` ```math -p_{t,g} - \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \left( \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} \wedge \mathrm{pos}(t) > 0 +p_{t,g} - \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \left( \overline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} \wedge \mathrm{pos}(t) > 0 ``` ### `Generator-com-partly-start-up` @@ -764,14 +885,14 @@ Generator_com_partly_start_up: where: Generator_committable AND Generator_partly_tightened expression: >- Generator_p - shift(Generator_p, along=snapshot, offset=1) - - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_up * Generator_p_nom) * Generator_status + - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom) * Generator_status + Generator_p_min_pu * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - + (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_up * Generator_p_nom - Generator_ramp_limit_start_up * Generator_p_nom) + + (Generator_p_min_pu * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom - Generator_start_up_rate * Generator_p_nom) * Generator_start_up <= 0 ``` ```math -p_{t,g} - p_{t - 1,g} - \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot u_{t,g} + \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} + \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \mathrm{ru}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \mathrm{ru}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} +p_{t,g} - p_{t - 1,g} - \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \widetilde{\mathrm{ru}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot u_{t,g} + \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} + \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \widetilde{\mathrm{ru}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} ``` ### `Generator-com-partly-shut-down` @@ -785,14 +906,14 @@ Generator_com_partly_shut_down: where: Generator_committable AND Generator_partly_tightened expression: >- shift(Generator_p, along=snapshot, offset=1) - Generator_p - - Generator_ramp_limit_shut_down * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - + (Generator_ramp_limit_shut_down * Generator_p_nom - Generator_ramp_limit_down * Generator_p_nom) * Generator_status - - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_down * Generator_p_nom - Generator_ramp_limit_shut_down * Generator_p_nom) + - Generator_shut_down_rate * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) + + (Generator_shut_down_rate * Generator_p_nom - Generator_ramp_down_rate * Generator_p_nom) * Generator_status + - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom - Generator_shut_down_rate * Generator_p_nom) * Generator_start_up <= 0 ``` ```math -p_{t - 1,g} - p_{t,g} - \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} + \left( \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot u_{t,g} - \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \mathrm{rd}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \mathrm{rd}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} +p_{t - 1,g} - p_{t,g} - \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t - 1,g} + \left( \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \widetilde{\mathrm{rd}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot u_{t,g} - \left( \underline{\mathrm{p}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} + \widetilde{\mathrm{rd}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} - \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \right) \cdot \mathit{up}_{t,g} \le 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{tight}_{g} ``` ### `Generator_previous_status` @@ -817,17 +938,17 @@ Generator_previous_status: ```yaml Generator_previous_p: description: >- - the output a generator carries into a snapshot — nothing at the start of - the horizon, which is why a unit that came in running carries no ramp row - there + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [snapshot, generator] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } otherwise: shift(Generator_p, along=snapshot, offset=1) ``` ```math -\overleftarrow{p}_{t,g} = \begin{cases} 0 & \text{if } \mathrm{pos}(t) = 0 \\ p_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\overleftarrow{p}_{t,g} = \begin{cases} \mathrm{u}^{0}_{g} \cdot \mathrm{p}^{0}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ p_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_up_rate` @@ -837,14 +958,14 @@ Generator_ramp_up_rate: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [generator] + dims: [snapshot, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 ``` ```math -\widetilde{\mathrm{ru}}_{g} = \begin{cases} \mathrm{ru}_{g} & \text{if } \mathrm{ru}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{ru}}_{t,g} = \begin{cases} \mathrm{ru}_{t,g} & \text{if } \mathrm{ru}_{t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_ramp_down_rate` @@ -854,14 +975,14 @@ Generator_ramp_down_rate: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [generator] + dims: [snapshot, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 ``` ```math -\widetilde{\mathrm{rd}}_{g} = \begin{cases} \mathrm{rd}_{g} & \text{if } \mathrm{rd}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} +\widetilde{\mathrm{rd}}_{t,g} = \begin{cases} \mathrm{rd}_{t,g} & \text{if } \mathrm{rd}_{t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` ### `Generator_start_up_rate` @@ -898,6 +1019,52 @@ Generator_shut_down_rate: \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, g \in \mathcal{G} ``` +### `Generator_ramp_up_allowance` + +```yaml +Generator_ramp_up_allowance: + description: >- + how far a generator may raise output between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom + * (Generator_status - Generator_previous_status) + otherwise: Generator_ramp_up_rate * Generator_p_nom +``` + +```math +\Delta^{+}_{t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \overleftarrow{u}_{t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( u_{t,g} - \overleftarrow{u}_{t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +### `Generator_ramp_down_allowance` + +```yaml +Generator_ramp_down_allowance: + description: >- + how far a generator may lower output between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_down_rate * Generator_p_nom * Generator_status + + Generator_shut_down_rate * Generator_p_nom + * (Generator_previous_status - Generator_status) + otherwise: Generator_ramp_down_rate * Generator_p_nom +``` + +```math +\Delta^{-}_{t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot \left( \overleftarrow{u}_{t,g} - u_{t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + #### Variable domains **`Generator_p`** @@ -948,6 +1115,26 @@ u_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \math \mu^{u}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} ``` +### `Generator_came_in_running_unless_committable` + +```yaml +Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. The spec does not state that row, so it refuses the data +``` + +```math +\mathrm{u}^{0}_{g} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} \wedge \left( \mathrm{ru}_{t,g} \text{ is defined} \vee \mathrm{rd}_{t,g} \text{ is defined} \right) +``` + ### `Generator_maintenance_events_positive` ```yaml diff --git a/examples/pypsa_linearized_uc.yaml b/examples/pypsa_linearized_uc.yaml index 6c11d5c4..2606c5b4 100644 --- a/examples/pypsa_linearized_uc.yaml +++ b/examples/pypsa_linearized_uc.yaml @@ -8,7 +8,11 @@ description: >- starts and its stops are shares in [0, 1] rather than binaries — a domain is the model's, not the data's — and four rows PyPSA adds only under the keyword tighten the relaxation where a unit's start and stop cost the same. - `examples/pypsa.yaml` stays the integer one. + The surface is generators, links and loads with a fixed build, in one + scenario, every asset active in every snapshot, and only a generator + committable. `examples/pypsa.yaml` stays the integer one, and states the + rest: a committable link or process, an extendable build, scenarios and + `active`. dimensions: snapshot: @@ -47,6 +51,11 @@ parameters: Generator_marginal_cost: description: cost of one unit of output dims: [snapshot, generator] + Generator_sign: + description: >- + the sign output enters its bus's balance with — PyPSA's `sign`, `1` + unless given, `-1` for a unit that draws power + dims: [generator] Link_p_nom: description: nominal power dims: [link] @@ -68,16 +77,21 @@ parameters: Load_p_set: description: demand dims: [snapshot, load] + Load_sign: + description: >- + the sign a load's demand enters its bus's balance with — PyPSA's + `sign`, `-1` unless given, `1` for a load that feeds its bus + dims: [load] Generator_committable: description: whether output is gated by an on/off status decision dims: [generator] dtype: bool Generator_ramp_limit_up: - description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit - dims: [generator] + description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [snapshot, generator] Generator_ramp_limit_down: - description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit - dims: [generator] + description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [snapshot, generator] Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [generator] @@ -96,6 +110,12 @@ parameters: description: one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep dims: [generator] dtype: int + Generator_p_init: + description: >- + the output a unit brought into the horizon — PyPSA's `p_init`, read + only where the unit came in running; no value means it is unknown, so + the unit carries no ramp row at the first snapshot + dims: [generator] Generator_must_stay_up: description: >- true while the up time a unit brought into the horizon still binds — @@ -257,18 +277,18 @@ expressions: otherwise: shift(Generator_status, along=snapshot, offset=1) Generator_previous_p: description: >- - the output a generator carries into a snapshot — nothing at the start of - the horizon, which is why a unit that came in running carries no ramp row - there + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that dims: [snapshot, generator] cases: - opening: { when: "position(snapshot) == 0", expression: 0 } + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } otherwise: shift(Generator_p, along=snapshot, offset=1) Generator_ramp_up_rate: description: >- the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row - dims: [generator] + dims: [snapshot, generator] cases: given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } otherwise: 1 @@ -276,7 +296,7 @@ expressions: description: >- the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row - dims: [generator] + dims: [snapshot, generator] cases: given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } otherwise: 1 @@ -296,6 +316,34 @@ expressions: cases: given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } otherwise: 1 + Generator_ramp_up_allowance: + description: >- + how far a generator may raise output between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom + * (Generator_status - Generator_previous_status) + otherwise: Generator_ramp_up_rate * Generator_p_nom + Generator_ramp_down_allowance: + description: >- + how far a generator may lower output between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_down_rate * Generator_p_nom * Generator_status + + Generator_shut_down_rate * Generator_p_nom + * (Generator_previous_status - Generator_status) + otherwise: Generator_ramp_down_rate * Generator_p_nom constraints: Generator_fix_p_lower: @@ -320,13 +368,14 @@ constraints: description: >- `Bus-nodal_balance` — what is generated at a bus, less what the links take away, plus what arrives over them after losses, meets the load - there + there. Each generator and load term enters with its component's `sign` + (`constraints.py:1428-1429`, `:1538`) dims: [snapshot, bus] expression: >- - sum(Generator_p, by=Generator_bus, over=generator, into=bus) + sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) - sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, by=Link_output_bus, over=link_output, into=bus) - == sum(Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) Generator_com_p_lower: description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" dims: [snapshot, generator] @@ -377,36 +426,28 @@ constraints: expression: Generator_status == 0 Generator_p_ramp_limit_up: description: >- - `Generator-p-ramp_limit_up` — a committed unit raises output no faster - than its limit while it was already on, and no further than its + `Generator-p-ramp_limit_up` — a generator raises output no faster than + its ramp limit of the build, and a committed one no further than its start-up ramp in the snapshot it turns on. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in dims: [snapshot, generator] where: >- - Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) - expression: >- - Generator_p - Generator_previous_p <= - Generator_ramp_up_rate * Generator_p_nom * Generator_previous_status - + Generator_start_up_rate * Generator_p_nom - * (Generator_status - Generator_previous_status) + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot) > 0 OR Generator_status_initial == 0 OR Generator_p_init) + expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance Generator_p_ramp_limit_down: description: >- - `Generator-p-ramp_limit_down` — a committed unit lowers output no - faster than its limit while it stays on, and no further than its + `Generator-p-ramp_limit_down` — a generator lowers output no faster than + its ramp limit of the build, and a committed one no further than its shut-down ramp in the snapshot it turns off. A unit that came into the - horizon running brought an unknown output, so it carries no row at the - first snapshot + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in dims: [snapshot, generator] where: >- - Generator_committable AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) - AND (position(snapshot) > 0 OR Generator_status_initial == 0) - expression: >- - Generator_previous_p - Generator_p <= - Generator_ramp_down_rate * Generator_p_nom * Generator_status - + Generator_shut_down_rate * Generator_p_nom - * (Generator_previous_status - Generator_status) + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot) > 0 OR Generator_status_initial == 0 OR Generator_p_init) + expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance Generator_status_p_fixed_upper: description: "`Generator-status-p-fixed-upper` — a status is at most one, an explicit row as PyPSA writes it" dims: [snapshot, generator] @@ -465,8 +506,8 @@ constraints: where: Generator_committable AND Generator_partly_tightened expression: >- shift(Generator_p, along=snapshot, offset=1) - - Generator_ramp_limit_shut_down * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - - (Generator_p_max_pu * Generator_p_nom - Generator_ramp_limit_shut_down * Generator_p_nom) + - Generator_shut_down_rate * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) + - (Generator_p_max_pu * Generator_p_nom - Generator_shut_down_rate * Generator_p_nom) * (Generator_status - Generator_start_up) <= 0 Generator_com_p_current: description: "`Generator-com-p-current` — output fits the share on, and the share starting up only up to the start-up ramp" @@ -474,16 +515,16 @@ constraints: where: Generator_committable AND Generator_partly_tightened AND position(snapshot) > 0 expression: >- Generator_p - Generator_p_max_pu * Generator_p_nom * Generator_status - + (Generator_p_max_pu * Generator_p_nom - Generator_ramp_limit_start_up * Generator_p_nom) * Generator_start_up <= 0 + + (Generator_p_max_pu * Generator_p_nom - Generator_start_up_rate * Generator_p_nom) * Generator_start_up <= 0 Generator_com_partly_start_up: description: "`Generator-com-partly-start-up` — raising output while a share is starting up is bounded by the ramp of the share on and the start-up ramp of the share coming on" dims: [snapshot, generator] where: Generator_committable AND Generator_partly_tightened expression: >- Generator_p - shift(Generator_p, along=snapshot, offset=1) - - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_up * Generator_p_nom) * Generator_status + - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom) * Generator_status + Generator_p_min_pu * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - + (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_up * Generator_p_nom - Generator_ramp_limit_start_up * Generator_p_nom) + + (Generator_p_min_pu * Generator_p_nom + Generator_ramp_up_rate * Generator_p_nom - Generator_start_up_rate * Generator_p_nom) * Generator_start_up <= 0 Generator_com_partly_shut_down: description: "`Generator-com-partly-shut-down` — lowering output while a share is shutting down is bounded likewise, by the shut-down ramp" @@ -491,9 +532,9 @@ constraints: where: Generator_committable AND Generator_partly_tightened expression: >- shift(Generator_p, along=snapshot, offset=1) - Generator_p - - Generator_ramp_limit_shut_down * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) - + (Generator_ramp_limit_shut_down * Generator_p_nom - Generator_ramp_limit_down * Generator_p_nom) * Generator_status - - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_limit_down * Generator_p_nom - Generator_ramp_limit_shut_down * Generator_p_nom) + - Generator_shut_down_rate * Generator_p_nom * shift(Generator_status, along=snapshot, offset=1) + + (Generator_shut_down_rate * Generator_p_nom - Generator_ramp_down_rate * Generator_p_nom) * Generator_status + - (Generator_p_min_pu * Generator_p_nom + Generator_ramp_down_rate * Generator_p_nom - Generator_shut_down_rate * Generator_p_nom) * Generator_start_up <= 0 objective: @@ -507,6 +548,17 @@ objective: + sum(Generator_shut_down * Generator_shut_down_cost) assumptions: + Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. The spec does not state that row, so it refuses the data Generator_maintenance_events_positive: holds: "Generator_maintenance_events > 0" where: "Generator_maintainable" diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index a057da29..40bfb5c4 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1778,5 +1778,39 @@ "Process-p-ramp_limit_down": 4, "Process-p-ramp_limit_up": 4 } + }, + "rung_47_linearized_ramps": { + "columns": { + "Generator-p": 32, + "Generator-shut_down": 12, + "Generator-start_up": 12, + "Generator-status": 12, + "Link-p": 4 + }, + "global_constraints": {}, + "objective": 12862.5, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-com-p-before": 3, + "Generator-com-p-current": 3, + "Generator-com-p-lower": 12, + "Generator-com-p-upper": 12, + "Generator-com-partly-shut-down": 3, + "Generator-com-partly-start-up": 3, + "Generator-com-status-min_up_time_must_stay_up": 0, + "Generator-com-transition-shut-down": 12, + "Generator-com-transition-start-up": 12, + "Generator-fix-p-lower": 20, + "Generator-fix-p-upper": 20, + "Generator-p-ramp_limit_down": 13, + "Generator-p-ramp_limit_up": 10, + "Generator-shut_down-p-fixed-upper": 12, + "Generator-start_up-p-fixed-upper": 12, + "Generator-status-p-fixed-upper": 12, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_47_linearized_ramps.py b/examples/references/pypsa/rung_47_linearized_ramps.py new file mode 100644 index 00000000..1bea7ea6 --- /dev/null +++ b/examples/references/pypsa/rung_47_linearized_ramps.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 47: the relaxed file's ramps and signs — tightening at the full build, a ramp limit per snapshot, a `p_init`, a unit that is not committable, and a sign.""" + +from __future__ import annotations + +import math + +import spine + +MODEL = 'pypsa_linearized_uc.yaml' +OPTIMIZE = {'linearized_unit_commitment': True} + + +def build(): + """The spine plus a relax bus: five units that each carry one of the rows under review, a feeding load and a dear backup.""" + n = spine.build() + n.add('Bus', 'relax') + n.add( + 'Generator', + 'tight47', + bus='relax', + committable=True, + p_nom=50, + p_min_pu=0.2, + marginal_cost=2, + up_time_before=0, + ramp_limit_start_up=0.5, + ramp_limit_shut_down=0.5, + start_up_cost=10, + shut_down_cost=10, + ) + n.add( + 'Generator', + 'steep47', + bus='relax', + committable=True, + p_nom=40, + marginal_cost=3, + start_up_cost=1, + ramp_limit_up=[0.25, 0.25, math.nan, 0.25], + ramp_limit_down=[0.25, 0.25, 0.25, math.nan], + ramp_limit_start_up=0.25, + ) + n.add( + 'Generator', + 'warm47', + bus='relax', + committable=True, + p_nom=40, + marginal_cost=50, + start_up_cost=1, + p_init=40, + ramp_limit_down=0.25, + ramp_limit_shut_down=0.5, + ) + n.add('Generator', 'fixed47', bus='relax', p_nom=60, marginal_cost=1, ramp_limit_up=0.25, ramp_limit_down=0.25) + n.add('Generator', 'sink47', bus='relax', p_nom=20, p_min_pu=0.5, sign=-1) + n.add('Generator', 'backup47', bus='relax', p_nom=300, marginal_cost=500) + n.add('Load', 'feed47', bus='relax', p_set=10, sign=1) + n.add('Load', 'swing47', bus='relax', p_set=[60, 60, 140, 40]) + return n diff --git a/examples/symbols/pypsa_linearized_uc.yaml b/examples/symbols/pypsa_linearized_uc.yaml index a6516c3f..892661eb 100644 --- a/examples/symbols/pypsa_linearized_uc.yaml +++ b/examples/symbols/pypsa_linearized_uc.yaml @@ -57,3 +57,8 @@ names: Generator_maintenance: '\mu' Generator_maintenance_start: '\mu^{\mathrm{up}}' Generator_maintenance_status: '\mu^{u}' + Generator_sign: '\mathrm{sgn}' + Load_sign: '\mathrm{sgn}^{\mathrm{load}}' + Generator_p_init: '\mathrm{p}^{0}' + Generator_ramp_up_allowance: '\Delta^{+}' + Generator_ramp_down_allowance: '\Delta^{-}' From 9270a71f583bf10510d0af8a3bb0a09b14d3c11a Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 12:39:35 +0200 Subject: [PATCH 37/75] docs(pypsa): a start and a stop cost what they cost, with no snapshot or period weight --- docs/examples/pypsa.md | 99 +++++++++++++++++-- examples/pypsa.yaml | 14 +-- examples/references/pypsa/references.json | 24 +++++ .../pypsa/rung_48_unweighted_start_up.py | 44 +++++++++ 4 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 examples/references/pypsa/rung_48_unweighted_start_up.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 02ee344e..5c736dc7 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -607,7 +607,7 @@ def build(): | [`{c}-com-up-time`, `-down-time`](#generator-com-up-time) | done | `sum_back(window=min_up_time)` | | [`{c}-com-status-min_up_time_must_stay_up`](#generator-com-status-min_up_time_must_stay_up) | done | the window is a prep mask — `position()` takes a literal | | [`{c}-com-status-min_down_time_must_stay_up`](#generator-com-status-min_down_time_must_stay_up) | done | the same prep mask over the down time brought in, status zero; PyPSA's name says `_must_stay_up`; rung 24 records it | -| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | | +| [`stand_by_cost`, `start_up_cost`, `shut_down_cost`](#objective) | done | a start and a stop carry no snapshot or period weight, rung 48 | | [`{c}-com-p-before/-current/-partly-*`](pypsa_linearized_uc.md) | done | rungs 12, 44 and 47, a file of its own: Generator commitment on fixed builds | @@ -3834,6 +3834,85 @@ def build(): +### Rung 48 — a start and a stop, unweighted + +`n.optimize(multi_investment_periods=True)` with a committable unit that starts +and stops. PyPSA adds `start_up_cost * start_up` and +`shut_down_cost * shut_down` to the objective without the snapshot's objective +weight and without the period's weight (`optimize.py:414-429`). It weights every +other operating term by both (`optimize.py:262-264`). Over scenarios, it weights +the start and stop costs by the scenario's weight, as every operating term +(`optimize.py:448-452`). The file states the two terms in `scenario_opex` +without a weight, so the scenario weight is the only one they carry. + +The rung builds a committable peaker that starts once and stops once in each of +two periods, with period weights `1.0` and `0.5` and snapshot weights that are +not `1.0`. PyPSA solves to `7325.0`, and to `6525.0` without the start and stop +costs. The difference is `800.0`, the four events at their unweighted cost. The +earlier file weighted them by the period, which reads `600.0` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`start_up_cost`, `shut_down_cost`](#objective) under `multi_investment_periods` and snapshot weights | done | no snapshot weight and no period weight; the scenario weight only | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7325.0`, 72 rows. + +
+The network, as PyPSA code + +`rung_48_unweighted_start_up.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 48: a start and a stop cost what they cost — no snapshot weight and no period weight on them, over two weighted periods.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: a committable peaker that starts and stops once in each period.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(3)] + [(2030, datetime(2030, 1, 1, t)) for t in range(3)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 1.5, 2.5] + n.add('Bus', 'grid') + n.add('Generator', 'base48', bus='grid', p_nom=60, marginal_cost=10) + n.add('Generator', 'dear48', bus='grid', p_nom=100, marginal_cost=90) + n.add( + 'Generator', + 'peak48', + bus='grid', + p_nom=50, + marginal_cost=20, + committable=True, + p_min_pu=0.4, + start_up_cost=300, + shut_down_cost=100, + up_time_before=0, + ) + n.add('Load', 'town48', bus='grid', p_set=[50, 100, 50, 50, 100, 50]) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4241,7 +4320,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | -| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | | $`\mathrm{r}^{+}`$ | `Carrier_relative_growth` over $`\mathcal{I}`$ — the share of the previous period's additions a carrier's growth limit reads — PyPSA's `max_relative_growth` clipped at zero, so a negative share adds nothing and never tightens the limit | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | @@ -9160,7 +9239,7 @@ tech_capacity_expansion: ```yaml scenario_opex: - description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight + description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) @@ -9176,18 +9255,18 @@ scenario_opex: + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_start_up * Process_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_shut_down * Process_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \qquad \forall\, \xi \in \Xi ``` ### `Carrier_additions` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 70cbeee9..47c77823 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -2061,7 +2061,7 @@ expressions: + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) scenario_opex: - description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight + description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) expression: >- sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) @@ -2077,14 +2077,14 @@ expressions: + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_start_up * Generator_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_shut_down * Generator_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_start_up * Link_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_shut_down * Link_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_start_up * Process_start_up_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_shut_down * Process_shut_down_cost * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) Carrier_additions: description: >- what a carrier adds in a period — every extendable component of that diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 40bfb5c4..afb37f78 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1812,5 +1812,29 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_48_unweighted_start_up": { + "columns": { + "Generator-p": 18, + "Generator-shut_down": 6, + "Generator-start_up": 6, + "Generator-status": 6 + }, + "global_constraints": {}, + "objective": 7325.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 6, + "Generator-com-p-lower": 6, + "Generator-com-p-upper": 6, + "Generator-com-transition-shut-down": 6, + "Generator-com-transition-start-up": 6, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Generator-shut_down-p-fixed-upper": 6, + "Generator-start_up-p-fixed-upper": 6, + "Generator-status-p-fixed-upper": 6 + } } } diff --git a/examples/references/pypsa/rung_48_unweighted_start_up.py b/examples/references/pypsa/rung_48_unweighted_start_up.py new file mode 100644 index 00000000..5ff211ce --- /dev/null +++ b/examples/references/pypsa/rung_48_unweighted_start_up.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 48: a start and a stop cost what they cost — no snapshot weight and no period weight on them, over two weighted periods.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +OPTIMIZE = {'multi_investment_periods': True} + + +def build(): + """A whole network, not the spine: a committable peaker that starts and stops once in each period.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(3)] + [(2030, datetime(2030, 1, 1, t)) for t in range(3)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0, 1.5, 2.5] + n.add('Bus', 'grid') + n.add('Generator', 'base48', bus='grid', p_nom=60, marginal_cost=10) + n.add('Generator', 'dear48', bus='grid', p_nom=100, marginal_cost=90) + n.add( + 'Generator', + 'peak48', + bus='grid', + p_nom=50, + marginal_cost=20, + committable=True, + p_min_pu=0.4, + start_up_cost=300, + shut_down_cost=100, + up_time_before=0, + ) + n.add('Load', 'town48', bus='grid', p_set=[50, 100, 50, 50, 100, 50]) + return n From e2410a51b98959a8fdc0949d9082c08427a1c30b Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 12:40:05 +0200 Subject: [PATCH 38/75] docs(pypsa): a growth limit binds only under multi_investment_periods --- docs/examples/pypsa.md | 52 ++++++++++++++++++- examples/pypsa.yaml | 4 +- examples/references/pypsa/references.json | 22 ++++++++ .../pypsa/rung_49_single_period_growth.py | 17 ++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 examples/references/pypsa/rung_49_single_period_growth.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5c736dc7..83ca4f13 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1381,6 +1381,7 @@ own dimensions. | [`Generator-fix-p-*`, `-ext-p-*`, `-ext-p_nom-*`](#generator-fix-p-lower) | done | rungs 1 and 3, masked by `active` | | [`Carrier-growth_limit`](#carrier-growth_limit) | done | every extendable component of the carrier, counted in the first period a build stands in; `edge=0` at the first period | | [`Carrier-growth_limit`](#carrier-growth_limit) with a negative `max_relative_growth` | done | rung 39 | +| [`Carrier-growth_limit`](#carrier-growth_limit) without `multi_investment_periods` | done | rung 49; not built, so data prep feeds no `max_growth` | | [objective](#objective) | done | period weight on operation; capacity once per period it stands in | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) per period, ramps at period starts | done | rung 29 | | [`StorageUnit-energy_balance`](#storageunit-energy_balance), [`Store-energy_balance`](#store-energy_balance) for storage built in a later period or retired early | done | rung 32 | @@ -3913,6 +3914,55 @@ def build(): +### Rung 49 — a growth limit in one period + +`n.optimize()` with a carrier that carries `max_growth`. PyPSA builds +`Carrier-growth_limit` only under `multi_investment_periods`, and returns +before it reads the carrier otherwise (`global_constraints.py:219-220`). A +single-period run has no growth limit. The file states the row where +`Carrier_max_growth` has a value, so data prep feeds no value on a +single-period run, and no row is built. + +The rung adds a cheap extendable wind unit with `max_growth = 10` to the spine. +PyPSA builds it to `67` and solves to `335.0`, the same as without the limit. +The same network as one investment period under `multi_investment_periods` +caps the build at `10` and solves to `2583.33` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`Carrier-growth_limit`](#carrier-growth_limit) without `multi_investment_periods` | done | not built; data prep feeds no `Carrier_max_growth` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `335.0`, 42 rows. + +
+The network, as PyPSA code + +`rung_49_single_period_growth.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 49: a carrier's growth limit without `multi_investment_periods` — PyPSA builds no row, so the build passes the cap.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Carrier', 'wind', max_growth=10) + n.add('Generator', 'wind49', bus='south', carrier='wind', p_nom_extendable=True, p_nom_max=100, capital_cost=5) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4102,7 +4152,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | | $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep | +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | | $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | | $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 47c77823..bf1b550d 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -613,7 +613,9 @@ parameters: description: >- most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it - (`global_constraints.py:226-230`), data prep + (`global_constraints.py:226-230`), data prep. PyPSA reads it only under + `multi_investment_periods` (`global_constraints.py:219-220`), so data + prep feeds no value otherwise dims: [carrier] Carrier_max_relative_growth: description: >- diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index afb37f78..b0135714 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1836,5 +1836,27 @@ "Generator-start_up-p-fixed-upper": 6, "Generator-status-p-fixed-upper": 6 } + }, + "rung_49_single_period_growth": { + "columns": { + "Generator-p": 12, + "Generator-p_nom": 1, + "Link-p": 4 + }, + "global_constraints": {}, + "objective": 335.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-ext-p-lower": 4, + "Generator-ext-p-upper": 4, + "Generator-ext-p_nom-lower": 1, + "Generator-ext-p_nom-upper": 1, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_49_single_period_growth.py b/examples/references/pypsa/rung_49_single_period_growth.py new file mode 100644 index 00000000..f1cb85c2 --- /dev/null +++ b/examples/references/pypsa/rung_49_single_period_growth.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 49: a carrier's growth limit without `multi_investment_periods` — PyPSA builds no row, so the build passes the cap.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Carrier', 'wind', max_growth=10) + n.add('Generator', 'wind49', bus='south', carrier='wind', p_nom_extendable=True, p_nom_max=100, capital_cost=5) + return n From 98e8297a5bb8e6813165aefaae3ed25775097a99 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 12:40:58 +0200 Subject: [PATCH 39/75] docs(pypsa): a load that is not active draws nothing from its bus --- docs/examples/pypsa.md | 79 ++++++++++++++++++- examples/pypsa.yaml | 22 +++++- examples/references/pypsa/references.json | 17 ++++ .../references/pypsa/rung_50_inactive_load.py | 16 ++++ examples/symbols/pypsa.yaml | 2 + 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 examples/references/pypsa/rung_50_inactive_load.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 83ca4f13..abeaa29d 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -86,6 +86,7 @@ def build(): | [`Link-fix-p-upper`](#link-fix-p-upper) | done | | | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | [`Bus-nodal_balance`](#bus-nodal_balance) with a component `sign` | done | rung 43 | +| [`Bus-nodal_balance`](#bus-nodal_balance) with a load that is not `active` | done | rung 50; `Load_demand` is zero where `Load_active` is false | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | | [`marginal_cost`](#objective) | done | | | [`marginal_cost_quadratic`](#objective) | done | rungs 10 and 36, below; Generator and Link `p`, Process `p`, StorageUnit `p_dispatch` only, and Store net `p` | @@ -3963,6 +3964,56 @@ def build(): +### Rung 50 — a load that is not active + +`n.optimize()` with a load whose `active` is false. PyPSA masks the load side +of `Bus-nodal_balance` by `active` (`constraints.py:1537-1538`), so an inactive +load draws nothing. A load has no build year and no lifetime, so its `active` is +the static flag alone, in every snapshot and every period +(`descriptors.py:135-136`). PyPSA refuses an `active` that differs by scenario +(`consistency.py:1195`). The file states `Load_active` over the load and +`Load_demand`, the load's signed demand where it is active and zero where it +is not. The balance reads `Load_demand`. A plain run feeds every load active, +and the row collapses to the standard one. + +The rung adds an inactive load to the spine's south bus. PyPSA solves to +`7380.0`, the same as without the load. With the load active, it solves to +`14730.0` (#620). + +| PyPSA | status | note | +| --- | --- | --- | +| [`Bus-nodal_balance`](#bus-nodal_balance) with a load that is not `active` | done | `Load_demand` is zero where `Load_active` is false | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `7380.0`, 32 rows. + +
+The network, as PyPSA code + +`rung_50_inactive_load.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 50: a load that is not `active` — PyPSA drops it from its bus's balance.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Load', 'idle50', bus='south', p_set=[30, 10, 20, 40], active=False) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4127,6 +4178,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | +| $`\mathrm{on}^{\mathrm{load}}`$ | `Load_active` over $`\mathcal{D}`$ — whether a load stands in the model — PyPSA's `active`. A load has no build year and no lifetime, so the flag holds in every snapshot. PyPSA refuses one that differs by scenario (`consistency.py:1195`) | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | | $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | @@ -4373,6 +4425,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | | $`\mathrm{r}^{+}`$ | `Carrier_relative_growth` over $`\mathcal{I}`$ — the share of the previous period's additions a carrier's growth limit reads — PyPSA's `max_relative_growth` clipped at zero, so a negative share adds nothing and never tightens the limit | +| $`\check{\mathrm{load}}`$ | `Load_demand` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — what a load draws from its bus's balance — its demand times its sign where it is active, nothing where it is not, since PyPSA drops an inactive load from the balance (`constraints.py:1537-1538`) | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | @@ -8362,8 +8415,8 @@ Bus_nodal_balance: carrying its flow, meets the load there, less half of every incident line's and transformer's loss — PyPSA dissipates a branch's loss half at either end. Each generator, storage unit, store and load term enters - with its component's `sign` (`constraints.py:1428-1429`, `:1538`). - A bus nothing is attached to has no row; PyPSA refuses one that + with its component's `sign` (`constraints.py:1428-1429`, `:1538`), and + an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] expression: >- @@ -8381,11 +8434,11 @@ Bus_nodal_balance: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_demand, by=Load_bus, over=load, into=bus) ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{\xi,t,d} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \check{\mathrm{load}}_{\xi,t,d} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` @@ -9359,6 +9412,24 @@ Carrier_relative_growth: \mathrm{r}^{+}_{i} = \begin{cases} \mathrm{r}_{i} & \text{if } \mathrm{r}_{i} > 0 \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I} ``` +### `Load_demand` + +```yaml +Load_demand: + description: >- + what a load draws from its bus's balance — its demand times its sign + where it is active, nothing where it is not, since PyPSA drops an + inactive load from the balance (`constraints.py:1537-1538`) + dims: [scenario, snapshot, load] + cases: + active: { when: Load_active, expression: Load_sign * Load_p_set } + otherwise: 0 +``` + +```math +\check{\mathrm{load}}_{\xi,t,d} = \begin{cases} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{\xi,t,d} & \text{if } \mathrm{on}^{\mathrm{load}}_{d} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ d \in \mathcal{D} +``` + ### `Line_s_monitored` ```yaml diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index bf1b550d..184921c1 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -524,6 +524,13 @@ parameters: `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`consistency.py:1187`) dims: [load] + Load_active: + description: >- + whether a load stands in the model — PyPSA's `active`. A load has no + build year and no lifetime, so the flag holds in every snapshot. PyPSA + refuses one that differs by scenario (`consistency.py:1195`) + dims: [load] + dtype: bool scenario_weight: description: PyPSA's `scenario_weightings.weight` — the probability of a future dims: [scenario] @@ -2109,6 +2116,15 @@ expressions: cases: positive: { when: Carrier_max_relative_growth > 0, expression: Carrier_max_relative_growth } otherwise: 0 + Load_demand: + description: >- + what a load draws from its bus's balance — its demand times its sign + where it is active, nothing where it is not, since PyPSA drops an + inactive load from the balance (`constraints.py:1537-1538`) + dims: [scenario, snapshot, load] + cases: + active: { when: Load_active, expression: Load_sign * Load_p_set } + otherwise: 0 Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, @@ -3634,8 +3650,8 @@ constraints: carrying its flow, meets the load there, less half of every incident line's and transformer's loss — PyPSA dissipates a branch's loss half at either end. Each generator, storage unit, store and load term enters - with its component's `sign` (`constraints.py:1428-1429`, `:1538`). - A bus nothing is attached to has no row; PyPSA refuses one that + with its component's `sign` (`constraints.py:1428-1429`, `:1538`), and + an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] expression: >- @@ -3653,7 +3669,7 @@ constraints: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == -sum(Load_sign * Load_p_set, by=Load_bus, over=load, into=bus) + == -sum(Load_demand, by=Load_bus, over=load, into=bus) Carrier_growth_limit: description: >- `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index b0135714..33f78c91 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1858,5 +1858,22 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_50_inactive_load": { + "columns": { + "Generator-p": 8, + "Link-p": 4 + }, + "global_constraints": {}, + "objective": 7380.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 8, + "Generator-fix-p-lower": 8, + "Generator-fix-p-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_50_inactive_load.py b/examples/references/pypsa/rung_50_inactive_load.py new file mode 100644 index 00000000..ab21aa70 --- /dev/null +++ b/examples/references/pypsa/rung_50_inactive_load.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 50: a load that is not `active` — PyPSA drops it from its bus's balance.""" + +from __future__ import annotations + +import spine + + +def build(): + """The spine plus this rung's additions, as a ``pypsa.Network``.""" + n = spine.build() + n.add('Load', 'idle50', bus='south', p_set=[30, 10, 20, 40], active=False) + return n diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 621c9fd4..d99d2cb3 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -107,6 +107,7 @@ names: Link_p_min_pu_nonneg: '\mathrm{nonneg}^{f}' Load_p_set: '\mathrm{load}' Load_sign: '\mathrm{sgn}^{\mathrm{load}}' + Load_active: '\mathrm{on}^{\mathrm{load}}' Generator_p_set: '\mathrm{p}^{\mathrm{set}}' Link_p_set: '\mathrm{f}^{\mathrm{set}}' snapshot_weightings_stores: '\mathrm{w}^{\mathrm{sto}}' @@ -219,6 +220,7 @@ names: Transformer_loss_offset: '\mathrm{b}^{\sigma}' Line_BODF: '\beta' Transformer_BODF: '\beta^{\sigma}' + Load_demand: '\check{\mathrm{load}}' Line_s_monitored: '\check{s}' Transformer_s_monitored: '\check{\sigma}' Outage_s: '\hat{s}' From 9bfb210a5904b2d57d07774a7b7bbb40061f068b Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 12:41:35 +0200 Subject: [PATCH 40/75] docs(pypsa): a security-constrained run dissipates no transmission loss --- docs/examples/pypsa.md | 43 ++++++++++++++++++++++++------------------ examples/pypsa.yaml | 21 ++++++++++++--------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index abeaa29d..a8dad8a7 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -2516,7 +2516,13 @@ its flow within its rating. PyPSA computes the sub-network's branch outage distribution factors (BODF) and copies each flow limit row with the outaged branch's flow, times its factor, added to the left-hand side (`abstract.py:443-489`). The copy keeps the row's sense, right-hand side and -loss term, and its extendable rating. An outage is a line or a transformer, and +extendable rating. It carries no loss term: PyPSA builds the model without +`transmission_losses` and `linearized_unit_commitment` (`abstract.py:437-441`) +and hands any other keyword to the solver (`abstract.py:491`), so a security +run is lossless and integer. Data prep feeds `transmission_losses` false there. +A network with no passive branch is the exception: PyPSA runs a plain +`n.optimize()` with every keyword (`abstract.py:429-435`), and no outage +exists. An outage is a line or a transformer, and a plain list names lines. The outaged branch is monitored too, at the factor `-1`. The file states the copies over an `outage` axis, with the factors as data prep, `Line_BODF` and `Transformer_BODF`. A plain run supplies no outage, @@ -2536,6 +2542,7 @@ bind, in `Transformer-fix-s-lower` against a line outage and in | [`Transformer-fix-s-*-security-…`](#transformer-fix-s-lower-security-for-c-outage-in-sub-network-n), [`Transformer-ext-s-*-security-…`](#transformer-ext-s-lower-security-for-c-outage-in-sub-network-n) | split | the same for a transformer | | a branch not active in a period | done | PyPSA keeps the copy with that branch's flow dropped, so the file reads its flow as zero there; a copy left with no variable is not built here, where linopy counts it; no rung records it | | a security-constrained run over scenarios | out | PyPSA `1.3.0` raises, see [Refusals](#refusals) | +| `transmission_losses`, `linearized_unit_commitment` in a security-constrained run | done | PyPSA builds neither, so the copies carry no loss term and data prep feeds `transmission_losses` false; no rung, since rung 30 is lossless | > ✔ `pypsa 1.3.0` solves this rung's network at objective `22113.333333333332`, 240 rows. @@ -4279,7 +4286,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | | $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) | | $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | -| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | | $`\overline{\ell}`$ | `Line_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | | $`\mathrm{a}`$ | `Line_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | | $`\mathrm{b}`$ | `Line_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | @@ -7376,11 +7383,11 @@ Line_fix_s_lower_security: dimension dims: [scenario, snapshot, line, outage] where: not Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom ``` ```math -\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -7397,11 +7404,11 @@ Line_fix_s_upper_security: states them all over the outage dimension dims: [scenario, snapshot, line, outage] where: not Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom ``` ```math -\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -7419,11 +7426,11 @@ Line_ext_s_lower_security: all over the outage dimension dims: [scenario, snapshot, line, outage] where: Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext ``` ```math -\check{s}_{\xi,t,k} - \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -7441,11 +7448,11 @@ Line_ext_s_upper_security: dimension dims: [scenario, snapshot, line, outage] where: Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext ``` ```math -\check{s}_{\xi,t,k} + \ell_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} ``` ### `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -7463,11 +7470,11 @@ Transformer_fix_s_lower_security: dimension dims: [scenario, snapshot, transformer, outage] where: not Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom ``` ```math -\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -7484,11 +7491,11 @@ Transformer_fix_s_upper_security: this block states them all over the outage dimension dims: [scenario, snapshot, transformer, outage] where: not Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom ``` ```math -\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -7506,11 +7513,11 @@ Transformer_ext_s_lower_security: all over the outage dimension dims: [scenario, snapshot, transformer, outage] where: Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext ``` ```math -\check{\sigma}_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -7528,11 +7535,11 @@ Transformer_ext_s_upper_security: outage dimension dims: [scenario, snapshot, transformer, outage] where: Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext ``` ```math -\check{\sigma}_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} ``` ### `Kirchhoff-Voltage-Law` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 184921c1..2aab8031 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -905,7 +905,10 @@ parameters: whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the - same; false with no segments is a lossless run + same; false with no segments is a lossless run. A security-constrained + run over a network with passive branches builds no loss: PyPSA does not + hand the keyword to `create_model` (`abstract.py:437-441`) but to the + solver (`:491`), so data prep feeds false there dims: [] dtype: bool Line_loss_max: @@ -3238,7 +3241,7 @@ constraints: dimension dims: [scenario, snapshot, line, outage] where: not Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom Line_fix_s_upper_security: description: >- `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` — @@ -3248,7 +3251,7 @@ constraints: states them all over the outage dimension dims: [scenario, snapshot, line, outage] where: not Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom Line_ext_s_lower_security: description: >- `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` — @@ -3259,7 +3262,7 @@ constraints: all over the outage dimension dims: [scenario, snapshot, line, outage] where: Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored - Line_loss + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext Line_ext_s_upper_security: description: >- `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` — @@ -3270,7 +3273,7 @@ constraints: dimension dims: [scenario, snapshot, line, outage] where: Line_s_nom_extendable AND Line_BODF - expression: Line_s_monitored + Line_loss + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext Transformer_fix_s_lower_security: description: >- `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -3281,7 +3284,7 @@ constraints: dimension dims: [scenario, snapshot, transformer, outage] where: not Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom Transformer_fix_s_upper_security: description: >- `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -3291,7 +3294,7 @@ constraints: this block states them all over the outage dimension dims: [scenario, snapshot, transformer, outage] where: not Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom Transformer_ext_s_lower_security: description: >- `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` @@ -3302,7 +3305,7 @@ constraints: all over the outage dimension dims: [scenario, snapshot, transformer, outage] where: Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored - Transformer_loss + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext Transformer_ext_s_upper_security: description: >- `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` @@ -3313,7 +3316,7 @@ constraints: outage dimension dims: [scenario, snapshot, transformer, outage] where: Transformer_s_nom_extendable AND Transformer_BODF - expression: Transformer_s_monitored + Transformer_loss + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext Kirchhoff_Voltage_Law: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the From 5bb13366676865b185421c8ab6fbdb1d770181bc Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 12:53:17 +0200 Subject: [PATCH 41/75] docs: the changelog lists the pypsa spec that covers every model class and component --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dfa965..de477a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- docs(pypsa): a single spec covers every pypsa model class and component ([#620](https://github.com/energy-models/mathspec/pull/620)) + ## 0.1.0rc1 (2026-09-25) The release candidate for 0.1.0, the first release on PyPI: From 830632b1c7c8b3647d0cee28165313f7f0ed87b7 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:30:46 +0200 Subject: [PATCH 42/75] docs(pypsa): an efficiency, a rate or a phase shift may change from snapshot to snapshot --- docs/examples/pypsa.md | 189 +++++++++++++++--- docs/examples/pypsa_linearized_uc.md | 4 +- examples/pypsa.yaml | 53 ++--- examples/pypsa_linearized_uc.yaml | 2 +- examples/references/pypsa/references.json | 43 ++++ .../pypsa/rung_60_efficiency_per_snapshot.py | 68 +++++++ tests/fixtures/pypsa_standard_shape.yaml | 6 + 7 files changed, 312 insertions(+), 53 deletions(-) create mode 100644 examples/references/pypsa/rung_60_efficiency_per_snapshot.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 09618db0..8ef11e87 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -87,6 +87,7 @@ def build(): | [`Bus-nodal_balance`](#bus-nodal_balance) | done | a loaded bus with nothing attached: PyPSA refuses, see X2 | | [`Bus-nodal_balance`](#bus-nodal_balance) with a component `sign` | done | rung 43 | | [`Bus-nodal_balance`](#bus-nodal_balance) with a load that is not `active` | done | rung 50; `Load_demand` is zero where `Load_active` is false | +| [`Bus-nodal_balance`](#bus-nodal_balance) with an efficiency or a rate per snapshot | done | rung 60 | | `Bus-meshed-*-nodal_balance` | out | the same balance rows, dealt into linopy containers by how many component columns name a bus — `meshed_thresholds`, an `n.optimize()` keyword defaulting to `[30, 100, 400]`. Same rows, same duals, another name; a modeler whose engine wants the split states it, the file does not (#123) | | [`marginal_cost`](#objective) | done | | | [`marginal_cost_quadratic`](#objective) | done | rungs 10 and 36, below; Generator and Link `p`, Process `p`, StorageUnit `p_dispatch` only, and Store net `p` | @@ -129,6 +130,7 @@ def build(): | [`StorageUnit-spill`](#variable-domains) | done | `where: inflow > 0`, `absence: zero`; bounds on the variable, as PyPSA's | | [`StorageUnit-fix-*`](#storageunit-fix-p_dispatch-lower), [`Store-fix-e-*`](#store-fix-e-lower) | done | | | [`StorageUnit-energy_balance`](#storageunit-energy_balance) | done | the charge carried into a snapshot is a cased quantity — cyclic, opening, carried; `(1-loss)**eh` is prep | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance) with efficiencies per snapshot | done | rung 60 | | [`Store-energy_balance`](#store-energy_balance) | done | same | | [`StorageUnit-p_set`](#storageunit-p_set), [`{c}-{attr}_set`](#generator-p_set) | done | `Generator-p_set`, `Link-p_set`, `StorageUnit-state_of_charge_set`, `Store-e_set`, `Line-s_set`; `Store-p_set`, `StorageUnit-p_dispatch_set`, `-p_store_set` in rung 37 | | [`marginal_cost_storage`, `spill_cost`](#objective) | done | | @@ -419,6 +421,7 @@ each type is three blocks by sense. | PyPSA type | status | note | | ------------------------------------- | ----------- | ------------------------------------------------- | | [`primary_energy`](#primary_energy) | split | a block per sense — sense as data is beyond #70; carrier weights are prep; one period in rung 35; per scenario in rung 40 | +| [`primary_energy`](#primary_energy) with a generator efficiency per snapshot | done | rung 60 | | [`operational_limit`](#operational_limit) | split | a block per sense; one period in rung 35; per scenario in rung 40 | | [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) | split | a block per sense; membership from PyPSA's carrier string is prep; per scenario in rung 40 | | [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) | split | a block per sense | @@ -493,6 +496,7 @@ def build(): | ----------------------- | ------ | --------------------------------- | | [`Line-s`](#variable-domains), [`Line-fix-s-*`](#line-fix-s-lower) | done | the ext and nominal rows sit under rung 3's pattern | | [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) | done | the cycle basis is data prep | +| [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) with a fixed phase shift per snapshot | done | rung 60 | > ✔ `pypsa 1.3.0` solves this rung's network at objective `23962.0`, 123 rows. @@ -4021,6 +4025,137 @@ def build(): +### Rung 60 — efficiencies per snapshot + +`n.optimize()` with a link, a process, a storage unit, a fuel unit and a +transformer whose coefficients change over time. PyPSA declares a link's +`efficiency`, `efficiency2`, …, a process's `rate0`, `rate1`, …, a storage +unit's `efficiency_store` and `efficiency_dispatch`, a generator's +`efficiency` and a transformer's `phase_shift` all `static or series`, and +reads each per snapshot. The balance reads a port's coefficient at the +snapshot the flow arrives: it shifts the flow first and multiplies it by the +coefficient after (`constraints.py:1518-1522`). The energy balance of a storage +unit reads both efficiencies per snapshot (`constraints.py:2081-2082`). A +`primary_energy` row divides a generator's output by its efficiency at the +same snapshot (`global_constraints.py:418-423`). A fixed phase shift enters the +cycle sum per snapshot (`constraints.py:1657-1663`). So the file states +`Link_efficiency`, `Process_rate`, `StorageUnit_efficiency_store`, +`StorageUnit_efficiency_dispatch`, `Generator_primary_energy_weight` and +`Transformer_phase_shift_weight` over `snapshot`. `Link_output_arrival` and +`Process_output_arrival` multiply the shifted flow by the coefficient. A +standing loss was already stated per snapshot, in `StorageUnit_retention` and +`Store_retention`. A plain run feeds the same value at each snapshot, and the +rows collapse to the standard ones. + +The rung feeds a sink from a source over a link and a process that both +deliver one snapshot late, beside a storage unit and a fuel unit under a +`primary_energy` cap. A separate triangle carries a transformer with a fixed +phase shift per snapshot. The `generators` weighting is uniform, as in rung 16. +PyPSA solves to `130562.09`. Each coefficient binds: with its mean at each +snapshot, PyPSA solves to these objectives (#620). + +| coefficient held at its mean | objective | +| --- | --- | +| link `efficiency` | `130774.59` | +| process `rate1` | `130622.09` | +| storage unit `efficiency_store` | `129770.09` | +| storage unit `efficiency_dispatch` | `129966.86` | +| generator `efficiency` | `131280.86` | +| transformer `phase_shift` | `130313.38` | + +Where a delayed port reads the coefficient matters too. With each series +moved one snapshot later, which reads it at the snapshot the flow departs, +PyPSA solves to `130676.26` for the link and to `130742.09` for the process. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Bus-nodal_balance`](#bus-nodal_balance) with an efficiency or a rate per snapshot | done | `Link_efficiency` and `Process_rate` span `snapshot`, read at the snapshot a delayed flow arrives | +| [`StorageUnit-energy_balance`](#storageunit-energy_balance) with efficiencies per snapshot | done | `StorageUnit_efficiency_store` and `StorageUnit_efficiency_dispatch` span `snapshot` | +| [`primary_energy`](#primary_energy) with a generator efficiency per snapshot | done | `Generator_primary_energy_weight` spans `snapshot` | +| [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) with a fixed phase shift per snapshot | done | `Transformer_phase_shift_weight` spans `snapshot` | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `130562.09418409193`, 133 rows. + +
+The network, as PyPSA code + +`rung_60_efficiency_per_snapshot.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 60: efficiencies per snapshot — a link, a process, a storage unit, a fuel unit under a primary-energy cap and a transformer with a fixed phase shift, each reading its coefficient per snapshot.""" + +from __future__ import annotations + +from datetime import datetime + +#: Four hourly stamps. The `generators` weighting is uniform, as rung 16's, so a +#: `delay` of one is a shift of one snapshot position. The `objective` and +#: `stores` columns stay non-uniform, so no cost or storage factor passes as identity. +SNAPSHOTS = [datetime(2015, 1, 1, hour) for hour in range(4)] +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0], 'stores': [0.5, 2.0, 1.5, 2.5], 'generators': [1.0, 1.0, 1.0, 1.0]} + +LINK_EFFICIENCY = [2.0, 3.5, 2.5, 3.0] +PROCESS_RATE = [0.9, 0.5, 0.8, 0.6] +EFFICIENCY_STORE = [0.9, 0.5, 0.9, 0.5] +EFFICIENCY_DISPATCH = [0.6, 0.9, 0.7, 0.95] +FUEL_EFFICIENCY = [0.3, 0.6, 0.4, 0.5] +PHASE_SHIFT = [0.0, 0.5, -0.3, 1.0] + + +def build(): + """A source feeding a sink over a delayed link and a delayed process, a storage unit and a capped fuel unit at the sink, and a triangle with a fixed phase shift per snapshot.""" + import pypsa + + n = pypsa.Network() + n.set_snapshots(SNAPSHOTS) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Carrier', 'fuel60', co2_emissions=1.0) + n.add('Bus', ['source', 'sink']) + n.add('Generator', 'spring60', bus='source', p_nom=200, marginal_cost=5) + n.add('Generator', 'backup60', bus='sink', p_nom=200, marginal_cost=100) + n.add( + 'Generator', 'fuel_unit60', bus='sink', carrier='fuel60', p_nom=40, marginal_cost=20, efficiency=FUEL_EFFICIENCY + ) + n.add('Link', 'heat_pump60', bus0='source', bus1='sink', p_nom=10, efficiency=LINK_EFFICIENCY, delay=1) + n.add('Process', 'boiler60', bus0='source', bus1='sink', p_nom=20, rate1=PROCESS_RATE, delay1=1) + n.add( + 'StorageUnit', + 'battery60', + bus='sink', + p_nom=20, + max_hours=2, + cyclic_state_of_charge=True, + efficiency_store=EFFICIENCY_STORE, + efficiency_dispatch=EFFICIENCY_DISPATCH, + ) + n.add('Load', 'sink_load60', bus='sink', p_set=[60.0, 90.0, 50.0, 100.0]) + n.add( + 'GlobalConstraint', + 'fuel_cap60', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=150, + ) + n.add('Bus', ['a', 'b', 'c']) + n.add('Generator', 'hydro60', bus='a', p_nom=300, marginal_cost=10) + n.add('Generator', 'diesel60', bus='c', p_nom=300, marginal_cost=200) + n.add('Load', 'town60', bus='c', p_set=[90.0, 75.0, 120.0, 105.0]) + n.add('Line', 'ab60', bus0='a', bus1='b', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Line', 'bc60', bus0='b', bus1='c', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Transformer', 'ca60', bus0='c', bus1='a', x=0.002, r=0.0002, s_nom=40, phase_shift=PHASE_SHIFT) + return n +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4120,7 +4255,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$ — whether the nominal power is a decision | | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | | $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\Xi \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | +| $`\eta`$ | `Link_efficiency` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers. Read at the snapshot the flow arrives, so a delayed port delivers at its arrival snapshot's efficiency (`constraints.py:1522`) | | $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. One for every scenario: PyPSA groups the ports by delay over all scenarios and shifts each group in every one (`constraints.py:1269`) | | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. One for every scenario, as the delay | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | @@ -4150,7 +4285,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | | $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | | $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | -| $`\alpha`$ | `Process_rate` over $`\Xi \times \mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | +| $`\alpha`$ | `Process_rate` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies. Read at the snapshot the transfer arrives, so a delayed port transfers at its arrival snapshot's rate (`constraints.py:1522`) | | $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once. One for every scenario, as a link's | | $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. One for every scenario, as the delay | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | @@ -4240,8 +4375,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | | $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | | $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | -| $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\Xi \times \mathcal{S}`$ — share of the power drawn from the bus that becomes charge | -| $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\Xi \times \mathcal{S}`$ — share of the charge drawn down that reaches the bus | +| $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of the power drawn from the bus that becomes charge | +| $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of the charge drawn down that reaches the bus | | $`\mathrm{sgn}^{h}`$ | `StorageUnit_sign` over $`\mathcal{S}`$ — the sign net dispatch enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\rho`$ | `StorageUnit_retention` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | | $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | @@ -4300,7 +4435,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | | $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | | $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | -| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians at each snapshot, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | | $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift`. The shift parameters carry no scenario: only a cycle row reads them, and PyPSA fails on a transformer in a cycle on a network with scenarios (`constraints.py:1654`) | | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | @@ -4312,7 +4447,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\Xi \times \mathcal{I}`$ — which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA reads a row's sense per scenario (`global_constraints.py:556`, `:748`, `:860`) | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\Xi \times \mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep. PyPSA reads it per scenario (`global_constraints.py:557`, `:749`, `:861`) | | $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | -| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row | | $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | | $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | | $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | @@ -4418,8 +4553,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | | $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial charge, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A unit built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | | $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | -| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed flow wraps from the period's end, and where it is not the flow still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | -| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | +| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow delayed by the port's `delay` within its investment period, times the port's efficiency at the snapshot the flow arrives; where the port is `cyclic_delay` the delayed flow wraps from the period's end, and where it is not the flow still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | +| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power delayed by the port's `delay` within its investment period, times the port's rate at the snapshot the transfer arrives; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | | $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | | $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | @@ -7564,7 +7699,7 @@ Kirchhoff_Voltage_Law: ``` ```math -\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} + \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} + \sum_{m \in \mathcal{M}} \mathit{Transformer\_phase\_shift}_{\xi,t,m} \cdot \mathrm{Transformer\_phase\_shift\_cycle\_weight}_{m,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +\sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} + \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{t,m,c} + \sum_{m \in \mathcal{M}} \mathit{Transformer\_phase\_shift}_{\xi,t,m} \cdot \mathrm{Transformer\_phase\_shift\_cycle\_weight}_{m,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` ### `Generator-p-ramp_limit_up` @@ -7890,7 +8025,7 @@ StorageUnit_energy_balance: ``` ```math -\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{\xi,s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{\xi,s}} + \left( \mathrm{inflow}_{\xi,t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{\xi,t,s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{\xi,t,s}} + \left( \mathrm{inflow}_{\xi,t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} ``` ### `Store-fix-e-lower` @@ -9135,22 +9270,23 @@ Store_energy_carried_in: ```yaml Link_output_arrival: description: >- - what a link delivers to an output port at a snapshot — its flow after the - port's efficiency, delayed by the port's `delay` within its investment - period; where the port is `cyclic_delay` the delayed flow wraps from the - period's end, and where it is not the flow still in transit at the - period's first snapshots is lost. A port that does not delay (`delay` + what a link delivers to an output port at a snapshot — its flow delayed + by the port's `delay` within its investment period, times the port's + efficiency at the snapshot the flow arrives; where the port is + `cyclic_delay` the delayed flow wraps from the period's end, and where it + is not the flow still in transit at the period's first snapshots is + lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay - expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) - otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) * Link_efficiency + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) * Link_efficiency ``` ```math -\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,t,o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,t,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` ### `Process_output_arrival` @@ -9159,21 +9295,22 @@ Link_output_arrival: Process_output_arrival: description: >- what a process transfers at a port at a snapshot — its internal power - times the port's rate, delayed by the port's `delay` within its - investment period; where the port is `cyclic_delay` the delayed transfer - wraps from the period's end, and where it is not the energy still in - transit at the period's first snapshots is lost. A port that does not + delayed by the port's `delay` within its investment period, times the + port's rate at the snapshot the transfer arrives; where the port is + `cyclic_delay` the delayed transfer wraps from the period's end, and + where it is not the energy still in transit at the period's first + snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not dims: [scenario, snapshot, process_output] cases: wrapping: when: Process_output_cyclic_delay - expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) - otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) * Process_rate + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) * Process_rate ``` ```math -\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,t,r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,t,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} ``` ### `GlobalConstraint_energy_weight` @@ -9277,7 +9414,7 @@ primary_energy: ``` ```math -\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{a}_{\xi,i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{a}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{a}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{a}_{\xi,i,t,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{a}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{a}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `operational_limit` diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 572c77af..56e8fb13 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -347,7 +347,7 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\mathcal{L}`$ — nominal power | | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | | $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | -| $`\eta`$ | `Link_efficiency` over $`\mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | +| $`\eta`$ | `Link_efficiency` over $`\mathcal{T} \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | | $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus | @@ -500,7 +500,7 @@ Bus_nodal_balance: ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{t,d} \right) \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{t,g} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{t,o} = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{t,d} \right) \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Generator-com-p-lower` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index f58e2ff8..2a5dcbae 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -229,8 +229,9 @@ parameters: description: >- share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather - than delivers - dims: [scenario, link_output] + than delivers. Read at the snapshot the flow arrives, so a delayed port + delivers at its arrival snapshot's efficiency (`constraints.py:1522`) + dims: [scenario, snapshot, link_output] Link_output_delay: description: >- snapshots a port's delivery lags its link's flow — PyPSA's `delay`, @@ -371,8 +372,10 @@ parameters: the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus - one and whose output rates are its efficiencies - dims: [scenario, process_output] + one and whose output rates are its efficiencies. Read at the snapshot the + transfer arrives, so a delayed port transfers at its arrival snapshot's + rate (`constraints.py:1522`) + dims: [scenario, snapshot, process_output] Process_output_delay: description: >- snapshots a port's transfer lags its process's internal power — PyPSA's @@ -713,10 +716,10 @@ parameters: dims: [scenario, storage_unit] StorageUnit_efficiency_store: description: share of the power drawn from the bus that becomes charge - dims: [scenario, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_efficiency_dispatch: description: share of the charge drawn down that reaches the bus - dims: [scenario, storage_unit] + dims: [scenario, snapshot, storage_unit] StorageUnit_sign: description: >- the sign net dispatch enters its bus's balance with — PyPSA's `sign`, @@ -965,12 +968,12 @@ parameters: dims: [transformer, outage] Transformer_phase_shift_weight: description: >- - a fixed transformer's phase shift in radians, signed by its orientation - in the cycle — a constant added to the cycle sum, data prep; zero for a + a fixed transformer's phase shift in radians at each snapshot, signed by + its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row - dims: [transformer, cycle] + dims: [snapshot, transformer, cycle] Transformer_phase_shift_varying: description: >- whether a transformer's phase shift is a decision — PyPSA's @@ -1050,9 +1053,9 @@ parameters: Generator_primary_energy_weight: description: >- the constrained attribute per unit of energy at the bus — the carrier's - `co2_emissions` over the generator's efficiency, data prep; a generator + `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row - dims: [scenario, global_constraint, generator] + dims: [scenario, global_constraint, snapshot, generator] StorageUnit_primary_energy_weight: description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row dims: [scenario, global_constraint, storage_unit] @@ -1958,32 +1961,34 @@ expressions: otherwise: Store_retention * shift(Store_e, along=snapshot, offset=1) Link_output_arrival: description: >- - what a link delivers to an output port at a snapshot — its flow after the - port's efficiency, delayed by the port's `delay` within its investment - period; where the port is `cyclic_delay` the delayed flow wraps from the - period's end, and where it is not the flow still in transit at the - period's first snapshots is lost. A port that does not delay (`delay` + what a link delivers to an output port at a snapshot — its flow delayed + by the port's `delay` within its investment period, times the port's + efficiency at the snapshot the flow arrives; where the port is + `cyclic_delay` the delayed flow wraps from the period's end, and where it + is not the flow still in transit at the period's first snapshots is + lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not dims: [scenario, snapshot, link_output] cases: wrapping: when: Link_output_cyclic_delay - expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) - otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) * Link_efficiency + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) * Link_efficiency Process_output_arrival: description: >- what a process transfers at a port at a snapshot — its internal power - times the port's rate, delayed by the port's `delay` within its - investment period; where the port is `cyclic_delay` the delayed transfer - wraps from the period's end, and where it is not the energy still in - transit at the period's first snapshots is lost. A port that does not + delayed by the port's `delay` within its investment period, times the + port's rate at the snapshot the transfer arrives; where the port is + `cyclic_delay` the delayed transfer wraps from the period's end, and + where it is not the energy still in transit at the period's first + snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not dims: [scenario, snapshot, process_output] cases: wrapping: when: Process_output_cyclic_delay - expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) - otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) * Process_rate + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) * Process_rate GlobalConstraint_energy_weight: description: >- what one unit of power at a snapshot counts for in a row — the diff --git a/examples/pypsa_linearized_uc.yaml b/examples/pypsa_linearized_uc.yaml index a6bfaccd..27428762 100644 --- a/examples/pypsa_linearized_uc.yaml +++ b/examples/pypsa_linearized_uc.yaml @@ -70,7 +70,7 @@ parameters: share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers - dims: [link_output] + dims: [snapshot, link_output] Link_marginal_cost: description: cost of one unit of flow dims: [snapshot, link] diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 33f78c91..e3c3865a 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1875,5 +1875,48 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_60_efficiency_per_snapshot": { + "columns": { + "Generator-p": 20, + "Line-s": 8, + "Link-p": 4, + "Process-p": 4, + "StorageUnit-p_dispatch": 4, + "StorageUnit-p_store": 4, + "StorageUnit-state_of_charge": 4, + "Transformer-s": 4 + }, + "global_constraints": { + "fuel_cap60": { + "sense": "<=", + "type": "primary_energy" + } + }, + "objective": 130562.09418409193, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 20, + "Generator-fix-p-lower": 20, + "Generator-fix-p-upper": 20, + "GlobalConstraint-fuel_cap60": 1, + "Kirchhoff-Voltage-Law": 4, + "Line-fix-s-lower": 8, + "Line-fix-s-upper": 8, + "Link-fix-p-lower": 4, + "Link-fix-p-upper": 4, + "Process-fix-p-lower": 4, + "Process-fix-p-upper": 4, + "StorageUnit-energy_balance": 4, + "StorageUnit-fix-p_dispatch-lower": 4, + "StorageUnit-fix-p_dispatch-upper": 4, + "StorageUnit-fix-p_store-lower": 4, + "StorageUnit-fix-p_store-upper": 4, + "StorageUnit-fix-state_of_charge-lower": 4, + "StorageUnit-fix-state_of_charge-upper": 4, + "Transformer-fix-s-lower": 4, + "Transformer-fix-s-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_60_efficiency_per_snapshot.py b/examples/references/pypsa/rung_60_efficiency_per_snapshot.py new file mode 100644 index 00000000..5d1b6258 --- /dev/null +++ b/examples/references/pypsa/rung_60_efficiency_per_snapshot.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 60: efficiencies per snapshot — a link, a process, a storage unit, a fuel unit under a primary-energy cap and a transformer with a fixed phase shift, each reading its coefficient per snapshot.""" + +from __future__ import annotations + +from datetime import datetime + +#: Four hourly stamps. The `generators` weighting is uniform, as rung 16's, so a +#: `delay` of one is a shift of one snapshot position. The `objective` and +#: `stores` columns stay non-uniform, so no cost or storage factor passes as identity. +SNAPSHOTS = [datetime(2015, 1, 1, hour) for hour in range(4)] +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0], 'stores': [0.5, 2.0, 1.5, 2.5], 'generators': [1.0, 1.0, 1.0, 1.0]} + +LINK_EFFICIENCY = [2.0, 3.5, 2.5, 3.0] +PROCESS_RATE = [0.9, 0.5, 0.8, 0.6] +EFFICIENCY_STORE = [0.9, 0.5, 0.9, 0.5] +EFFICIENCY_DISPATCH = [0.6, 0.9, 0.7, 0.95] +FUEL_EFFICIENCY = [0.3, 0.6, 0.4, 0.5] +PHASE_SHIFT = [0.0, 0.5, -0.3, 1.0] + + +def build(): + """A source feeding a sink over a delayed link and a delayed process, a storage unit and a capped fuel unit at the sink, and a triangle with a fixed phase shift per snapshot.""" + import pypsa + + n = pypsa.Network() + n.set_snapshots(SNAPSHOTS) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Carrier', 'fuel60', co2_emissions=1.0) + n.add('Bus', ['source', 'sink']) + n.add('Generator', 'spring60', bus='source', p_nom=200, marginal_cost=5) + n.add('Generator', 'backup60', bus='sink', p_nom=200, marginal_cost=100) + n.add( + 'Generator', 'fuel_unit60', bus='sink', carrier='fuel60', p_nom=40, marginal_cost=20, efficiency=FUEL_EFFICIENCY + ) + n.add('Link', 'heat_pump60', bus0='source', bus1='sink', p_nom=10, efficiency=LINK_EFFICIENCY, delay=1) + n.add('Process', 'boiler60', bus0='source', bus1='sink', p_nom=20, rate1=PROCESS_RATE, delay1=1) + n.add( + 'StorageUnit', + 'battery60', + bus='sink', + p_nom=20, + max_hours=2, + cyclic_state_of_charge=True, + efficiency_store=EFFICIENCY_STORE, + efficiency_dispatch=EFFICIENCY_DISPATCH, + ) + n.add('Load', 'sink_load60', bus='sink', p_set=[60.0, 90.0, 50.0, 100.0]) + n.add( + 'GlobalConstraint', + 'fuel_cap60', + type='primary_energy', + carrier_attribute='co2_emissions', + sense='<=', + constant=150, + ) + n.add('Bus', ['a', 'b', 'c']) + n.add('Generator', 'hydro60', bus='a', p_nom=300, marginal_cost=10) + n.add('Generator', 'diesel60', bus='c', p_nom=300, marginal_cost=200) + n.add('Load', 'town60', bus='c', p_set=[90.0, 75.0, 120.0, 105.0]) + n.add('Line', 'ab60', bus0='a', bus1='b', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Line', 'bc60', bus0='b', bus1='c', carrier='AC', x=0.002, r=0.0002, s_nom=120) + n.add('Transformer', 'ca60', bus0='c', bus1='a', x=0.002, r=0.0002, s_nom=40, phase_shift=PHASE_SHIFT) + return n diff --git a/tests/fixtures/pypsa_standard_shape.yaml b/tests/fixtures/pypsa_standard_shape.yaml index 76095a92..0b3774a6 100644 --- a/tests/fixtures/pypsa_standard_shape.yaml +++ b/tests/fixtures/pypsa_standard_shape.yaml @@ -549,6 +549,7 @@ parameters: - generator Generator_primary_energy_weight: - global_constraint + - snapshot - generator Generator_ramp_limit_down: - snapshot @@ -626,6 +627,7 @@ parameters: Link_committable: - link Link_efficiency: + - snapshot - link_output Link_expansion_cost_weight: - global_constraint @@ -767,6 +769,7 @@ parameters: - snapshot - process Process_rate: + - snapshot - process_output Process_shut_down_cost: - process @@ -785,8 +788,10 @@ parameters: StorageUnit_cyclic_state_of_charge: - storage_unit StorageUnit_efficiency_dispatch: + - snapshot - storage_unit StorageUnit_efficiency_store: + - snapshot - storage_unit StorageUnit_inflow: - snapshot @@ -912,6 +917,7 @@ parameters: Transformer_phase_shift_varying: - transformer Transformer_phase_shift_weight: + - snapshot - transformer - cycle Transformer_s_max_pu: From f196e9412651c650a7b87da4db3f3b917ffac3cd Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:31:15 +0200 Subject: [PATCH 43/75] docs: the changelog lists efficiencies per snapshot --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de477a90..ef6cee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs(pypsa): a single spec covers every pypsa model class and component ([#620](https://github.com/energy-models/mathspec/pull/620)) +- docs(pypsa): an efficiency, a rate or a phase shift may change from snapshot to snapshot ([#717](https://github.com/energy-models/mathspec/pull/717)) ## 0.1.0rc1 (2026-09-25) From 348d925d675f2cc5e83f8a95f420c614fb2d74e9 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:34:53 +0200 Subject: [PATCH 44/75] docs(pypsa): a growth limit counts an asset only in the first period it stands in A rung may now record a PyPSA bug: its intended objective from an oracle, and what PyPSA 1.3.0 gives instead. Rung 51 records PyPSA/PyPSA#1938. --- docs/examples/pypsa.md | 124 ++++++++++++++++-- examples/pypsa.yaml | 46 +++++-- examples/references/pypsa/reference.py | 72 ++++++++-- examples/references/pypsa/references.json | 24 ++++ .../pypsa/rung_51_growth_retired_asset.py | 64 +++++++++ tests/test_pypsa_references.py | 28 +++- tools/gallery.py | 20 ++- 7 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 examples/references/pypsa/rung_51_growth_retired_asset.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 09618db0..8d9c8cd1 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -24,11 +24,15 @@ A row is **done** once the file states it as the one block PyPSA builds. **split** means the same feasible region and optimum under a different statement, such as several `where:` blocks. **open** means not stated yet. **out** means never stated, deliberately: emitted only under the keyword, -scope or version the note names. A name carrying `{k}`, `{s}`, `{c}` or `{n}` stands +scope or version the note names. **diverges** means the file states the +intended math where PyPSA `1.3.0` has a bug; the note names the issue, and the +rung records the intended objective and what PyPSA gives until the fix ships. A name carrying `{k}`, `{s}`, `{c}` or `{n}` stands for the family PyPSA numbers per segment, scenario, outaged component or sub-network. -Each rung's banner states what PyPSA solved its reference network to. +Each rung's banner states what PyPSA solved its reference network to. A +rung that records a PyPSA bug, marked ✘, states what PyPSA gives beside the +intended objective. > Every rung's network is `spine.build()` plus the rung's own `n.add` calls, data inline; a keyword not passed is PyPSA's default. A banner states what PyPSA solved the rung to; how an engine attaches the network to the file, and what it makes of it, is that engine's own record. @@ -4021,6 +4025,106 @@ def build(): +### Rung 51 — a growth limit after an asset retires + +`n.optimize(multi_investment_periods=True)` with a carrier that carries +`max_growth`, and an extendable asset of that carrier that retires before the +last period. The file counts a build in the first period it stands in only: +`{c}_first_active` is one there and zero elsewhere. PyPSA `1.3.0` takes +`active.cumsum() == 1`, which stays true after the asset retires, so it counts +the asset again in every later period (`global_constraints.py:276`, +[PyPSA/PyPSA#1938](https://github.com/PyPSA/PyPSA/issues/1938)). + +The rung builds two solar units under one carrier with `max_growth = 10`. The +old one stands in 2020 only, the new one in 2030 only. Both build to `10` in +the file. PyPSA holds the new one at `10` minus the old one's build, so it +builds nothing in 2030 and solves to `5185.0`. The oracle is the same network +with a carrier per unit, each with the same limit: each carrier has one asset, +and the repeated row PyPSA builds for the old one repeats its own bound. It +solves to `3432.5`. Without `max_growth`, the network solves to `248.75`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Carrier-growth_limit`](#carrier-growth_limit) with an asset that retires | diverges | [PyPSA/PyPSA#1938](https://github.com/PyPSA/PyPSA/issues/1938); `{c}_first_active` is zero after the first period an asset stands in | + + +> ✘ `pypsa 1.3.0` solves this rung's network at objective `5185.0`, 26 rows, [PyPSA/PyPSA#1938](https://github.com/PyPSA/PyPSA/issues/1938). The intended objective is `3432.5`. + +
+The network, as PyPSA code + +`rung_51_growth_retired_asset.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 51: a carrier's growth limit counts an asset in the first period it stands in only, not again after it retires. + +PyPSA 1.3.0 counts an asset that retires in every later period too (PyPSA/PyPSA#1938). +The oracle gives each build its own carrier with the same limit: each carrier +then has one asset, which PyPSA counts in its first period, and a retired one +counted again repeats a row it already has. +""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +ISSUE = 1938 +OPTIMIZE = {'multi_investment_periods': True} + + +def network(carriers: dict[str, str]): + """Two periods, a solar unit that stands in 2020 only and one built in 2030, each under the carrier named for it.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'gas') + for carrier in sorted(set(carriers.values())): + n.add('Carrier', carrier, max_growth=10) + for name, build_year in (('solar_old', 2020), ('solar_new', 2030)): + n.add( + 'Generator', + name, + bus='grid', + carrier=carriers[name], + p_nom_extendable=True, + p_nom_max=50, + marginal_cost=1, + capital_cost=5, + build_year=build_year, + lifetime=10, + ) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=100, marginal_cost=80) + n.add('Load', 'town', bus='grid', p_set=[15, 20, 15, 20]) + return n + + +def build(): + """Both solar units under one carrier with `max_growth = 10`; the old one retires after 2020.""" + return network({'solar_old': 'solar', 'solar_new': 'solar'}) + + +def oracle(): + """The same network with a carrier per build: PyPSA counts each build in its first period only.""" + return [(1.0, network({'solar_old': 'solar20', 'solar_new': 'solar30'}))] +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4205,12 +4309,12 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{W}^{s}`$ | `Line_capital_weight` over $`\mathcal{K}`$ — the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{z}`$ | `Process_capital_weight` over $`\mathcal{J}`$ — the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{W}^{\sigma}`$ | `Transformer_capital_weight` over $`\mathcal{M}`$ — the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep | -| $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{f}`$ | `Link_first_active` over $`\mathcal{Y} \times \mathcal{L}`$ — one in the first period a link stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{h}`$ | `StorageUnit_first_active` over $`\mathcal{Y} \times \mathcal{S}`$ — one in the first period a storage unit stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | -| $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep | +| $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a generator that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{new}^{f}`$ | `Link_first_active` over $`\mathcal{Y} \times \mathcal{L}`$ — one in the first period a link stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a link that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{new}^{h}`$ | `StorageUnit_first_active` over $`\mathcal{Y} \times \mathcal{S}`$ — one in the first period a storage unit stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a storage unit that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a store that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a line that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a process that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | | $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | | $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | @@ -4249,7 +4353,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\Xi \times \mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | | $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | -| $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every unit stands throughout | +| $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots it stands in, past the first snapshot, data prep; false in a run where every unit stands throughout | | $`\mathrm{idle}`$ | `StorageUnit_inactive_snapshots` over $`\mathcal{S}`$ — how many snapshots a storage unit does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic unit reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | | $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | @@ -4269,7 +4373,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\Xi \times \mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | | $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | | $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | -| $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` past the first snapshot, data prep; false in a run where every store stands throughout | +| $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots it stands in, past the first snapshot, data prep; false in a run where every store stands throughout | | $`\mathrm{idle}^{e}`$ | `Store_inactive_snapshots` over $`\mathcal{V}`$ — how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic store reaches back this many snapshots further, so it closes on the last snapshot it stands in | | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | | $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index f58e2ff8..55a73a03 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -599,22 +599,46 @@ parameters: description: the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep dims: [transformer] Generator_first_active: - description: one in the first period a generator stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a generator stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a generator + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, generator] Link_first_active: - description: one in the first period a link stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a link stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a link + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, link] StorageUnit_first_active: - description: one in the first period a storage unit stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a storage unit stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a storage unit + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, storage_unit] Store_first_active: - description: one in the first period a store stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a store stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a store + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, store] Line_first_active: - description: one in the first period a line stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a line stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a line + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, line] Process_first_active: - description: one in the first period a process stands in, zero elsewhere — PyPSA's `active.cumsum() == 1`, data prep + description: >- + one in the first period a process stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a process + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) dims: [period, process] Carrier_max_growth: description: >- @@ -754,8 +778,9 @@ parameters: StorageUnit_opens_late: description: >- whether a snapshot is the first a storage unit stands in, where that is - not the first of the horizon — PyPSA's `active.cumsum() == 1` past the - first snapshot, data prep; false in a run where every unit stands throughout + not the first of the horizon — PyPSA's `active.cumsum() == 1` over the + snapshots it stands in, past the first snapshot, data prep; false in a + run where every unit stands throughout dims: [snapshot, storage_unit] dtype: bool StorageUnit_inactive_snapshots: @@ -836,8 +861,9 @@ parameters: Store_opens_late: description: >- whether a snapshot is the first a store stands in, where that is not the - first of the horizon — PyPSA's `active.cumsum() == 1` past the first - snapshot, data prep; false in a run where every store stands throughout + first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots + it stands in, past the first snapshot, data prep; false in a run where + every store stands throughout dims: [snapshot, store] dtype: bool Store_inactive_snapshots: diff --git a/examples/references/pypsa/reference.py b/examples/references/pypsa/reference.py index bd5f49a0..ed1e0194 100644 --- a/examples/references/pypsa/reference.py +++ b/examples/references/pypsa/reference.py @@ -17,7 +17,15 @@ the PyPSA model under review is the script itself. A rung stated by a file of its own names it as `MODEL`; one that needs `n.optimize` keywords names them as `OPTIMIZE`; one that names `BRANCH_OUTAGES` is solved by -`n.optimize.optimize_security_constrained` over them. PyPSA is not a dependency +`n.optimize.optimize_security_constrained` over them. + +A rung that names `ISSUE` records a PyPSA bug: the network PyPSA 1.3.0 gets +wrong, until the fix ships. Its `oracle()` returns networks PyPSA solves +correctly, each with the weight its objective takes in the intended one. The +record's `objective` is that intended objective, and `diverges` holds the issue +and what PyPSA gives instead: its objective, or the exception it raises. Rows +and columns are what PyPSA built, where it built any. `--check` fails where +PyPSA no longer diverges, and names the issue. PyPSA is not a dependency of this project; this script pins the versions the recorded numbers are from, and the `PyPSA references` workflow runs `--check` on every change. """ @@ -66,8 +74,7 @@ def record(n: pypsa.Network) -> dict[str, object]: } -def solved(rung: str) -> dict[str, object]: - n = build(rung) +def optimized(rung: str, n: pypsa.Network) -> pypsa.Network: outages = getattr(importlib.import_module(rung), 'BRANCH_OUTAGES', None) if outages is None: status, condition = n.optimize(solver_name='highs', **keywords(rung)) @@ -76,7 +83,49 @@ def solved(rung: str) -> dict[str, object]: solver_name='highs', branch_outages=outages, **keywords(rung) ) assert status == 'ok', f'{rung}: HiGHS did not solve — {status} / {condition}' - return record(n) + return n + + +def close(a: float, b: float) -> bool: + return math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-6) + + +def diverged(rung: str, issue: int) -> dict[str, object]: + """The intended objective from the rung's oracle, beside what PyPSA gives on the rung's own network.""" + intended = sum(weight * float(optimized(rung, n).objective) for weight, n in importlib.import_module(rung).oracle()) + try: + seen = record(optimized(rung, build(rung))) + except Exception as error: + return { + 'pypsa': pypsa.__version__, + 'objective': intended, + 'diverges': {'issue': issue, 'raises': type(error).__name__}, + } + return {**seen, 'objective': intended, 'diverges': {'issue': issue, 'objective': seen['objective']}} + + +def solved(rung: str) -> dict[str, object]: + issue = getattr(importlib.import_module(rung), 'ISSUE', None) + return record(optimized(rung, build(rung))) if issue is None else diverged(rung, issue) + + +def fixed(seen: dict[str, object]) -> bool: + """Whether PyPSA gives the intended objective on a rung that records one of its bugs.""" + diverges = seen.get('diverges', {}) + return 'objective' in diverges and close(diverges['objective'], seen['objective']) + + +def matches(kept: dict[str, object], seen: dict[str, object]) -> bool: + """Equal records, every objective compared to a tolerance.""" + + def split(r: dict[str, object]) -> tuple[list[float], dict[str, object]]: + diverges = r.get('diverges', {}) + numbers = [r['objective'], *([diverges['objective']] if 'objective' in diverges else [])] + rest = {k: v for k, v in diverges.items() if k != 'objective'} + return numbers, {**r, 'objective': None, 'diverges': rest} + + (a, rest_a), (b, rest_b) = split(kept), split(seen) + return rest_a == rest_b and len(a) == len(b) and all(map(close, a, b)) def main(argv: list[str]) -> int: @@ -86,14 +135,15 @@ def main(argv: list[str]) -> int: for rung in rungs(): seen = solved(rung) print(f'{rung}: objective {seen["objective"]}') + if fixed(seen): + issue = seen['diverges']['issue'] + print( + f'{rung}: pypsa {pypsa.__version__} solves PyPSA/PyPSA#{issue} as intended — drop the divergence', + file=sys.stderr, + ) + return 1 if check: - kept = stamped.get(rung) - if ( - kept is None - or not math.isclose(kept['objective'], seen['objective'], rel_tol=1e-9, abs_tol=1e-6) - or {k: v for k, v in kept.items() if k != 'objective'} - != {k: v for k, v in seen.items() if k != 'objective'} - ): + if (kept := stamped.get(rung)) is None or not matches(kept, seen): stale.append(rung) else: stamped[rung] = seen diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 33f78c91..13de643e 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1875,5 +1875,29 @@ "Link-fix-p-lower": 4, "Link-fix-p-upper": 4 } + }, + "rung_51_growth_retired_asset": { + "columns": { + "Generator-p": 8, + "Generator-p_nom": 2 + }, + "diverges": { + "issue": 1938, + "objective": 5185.0 + }, + "global_constraints": {}, + "objective": 3432.5, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 4, + "Carrier-growth_limit": 2, + "Generator-ext-p-lower": 4, + "Generator-ext-p-upper": 4, + "Generator-ext-p_nom-lower": 2, + "Generator-ext-p_nom-upper": 2, + "Generator-fix-p-lower": 4, + "Generator-fix-p-upper": 4 + } } } diff --git a/examples/references/pypsa/rung_51_growth_retired_asset.py b/examples/references/pypsa/rung_51_growth_retired_asset.py new file mode 100644 index 00000000..2417a27d --- /dev/null +++ b/examples/references/pypsa/rung_51_growth_retired_asset.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 51: a carrier's growth limit counts an asset in the first period it stands in only, not again after it retires. + +PyPSA 1.3.0 counts an asset that retires in every later period too (PyPSA/PyPSA#1938). +The oracle gives each build its own carrier with the same limit: each carrier +then has one asset, which PyPSA counts in its first period, and a retired one +counted again repeats a row it already has. +""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +ISSUE = 1938 +OPTIMIZE = {'multi_investment_periods': True} + + +def network(carriers: dict[str, str]): + """Two periods, a solar unit that stands in 2020 only and one built in 2030, each under the carrier named for it.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', 'grid') + n.add('Carrier', 'gas') + for carrier in sorted(set(carriers.values())): + n.add('Carrier', carrier, max_growth=10) + for name, build_year in (('solar_old', 2020), ('solar_new', 2030)): + n.add( + 'Generator', + name, + bus='grid', + carrier=carriers[name], + p_nom_extendable=True, + p_nom_max=50, + marginal_cost=1, + capital_cost=5, + build_year=build_year, + lifetime=10, + ) + n.add('Generator', 'backup', bus='grid', carrier='gas', p_nom=100, marginal_cost=80) + n.add('Load', 'town', bus='grid', p_set=[15, 20, 15, 20]) + return n + + +def build(): + """Both solar units under one carrier with `max_growth = 10`; the old one retires after 2020.""" + return network({'solar_old': 'solar', 'solar_new': 'solar'}) + + +def oracle(): + """The same network with a carrier per build: PyPSA counts each build in its first period only.""" + return [(1.0, network({'solar_old': 'solar20', 'solar_new': 'solar30'}))] diff --git a/tests/test_pypsa_references.py b/tests/test_pypsa_references.py index 17954f36..96621168 100644 --- a/tests/test_pypsa_references.py +++ b/tests/test_pypsa_references.py @@ -38,11 +38,12 @@ #: those rows after each row's own label, so they are matched through the #: recorded type and sense instead of by name. GC_TYPES = {name for name in ROWS_DECLARED if not name[0].isupper()} -RECORDED_ROWS: set[str] = set().union(*(record['rows'] for record in RECORDED.values())) -RECORDED_COLUMNS: set[str] = set().union(*(record['columns'] for record in RECORDED.values())) -GC_RECORDED: dict[str, dict] = { - label: gc for record in RECORDED.values() for label, gc in record['global_constraints'].items() -} +#: A rung that records a PyPSA bug PyPSA raises on has no rows, columns or global constraints of its own. +BUILT = [record for record in RECORDED.values() if 'raises' not in record.get('diverges', {})] +RECORDED_ROWS: set[str] = set().union(*(record['rows'] for record in BUILT)) +RECORDED_COLUMNS: set[str] = set().union(*(record['columns'] for record in BUILT)) +GC_RECORDED: dict[str, dict] = {label: gc for record in BUILT for label, gc in record['global_constraints'].items()} +DIVERGED = {stem: record for stem, record in RECORDED.items() if 'diverges' in record} @pytest.mark.parametrize('key', ['spine', *RUNGS]) @@ -95,7 +96,22 @@ def test_the_record_is_from_the_pinned_pypsa(stem: str): def test_the_recorded_solve_is_usable_as_an_oracle(stem: str): recorded = RECORDED[stem] assert math.isfinite(recorded['objective']), 'an oracle needs a finite objective' - assert recorded['rows'], 'an oracle needs the row counts an engine would compare' + if recorded in BUILT: + assert recorded['rows'], 'an oracle needs the row counts an engine would compare' + + +@pytest.mark.parametrize('stem', sorted(DIVERGED), ids=sorted(DIVERGED)) +def test_a_divergence_names_its_issue_and_what_pypsa_gives_instead(stem: str): + recorded = RECORDED[stem] + diverges = recorded['diverges'] + module = (REFERENCES / f'{stem}.py').read_text() + assert f'ISSUE = {diverges["issue"]}' in module, 'the record names the issue its rung script names' + assert any(f'PyPSA/PyPSA#{diverges["issue"]}' in text for text in PAGE_TEXTS), ( + 'the page links the issue a rung records' + ) + assert 'raises' in diverges or not math.isclose(diverges['objective'], recorded['objective'], rel_tol=1e-9), ( + 'a divergence records what PyPSA gives instead of the intended objective: an exception or another objective' + ) def test_pypsa_builds_no_variable_the_files_do_not_declare(): diff --git a/tools/gallery.py b/tools/gallery.py index f5879f1a..bf6c1d54 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -144,13 +144,25 @@ def _script(name: str) -> str: return f'`{name}.py`\n\n```python\n{(REFERENCES / f"{name}.py").read_text().strip()}\n```' +def _banner(recorded: dict) -> str: + """What PyPSA solved the rung to; for a rung that records a PyPSA bug, also what the file intends and the issue.""" + pypsa = f'`pypsa {recorded["pypsa"]}`' + if 'diverges' not in recorded: + return f"> ✔ {pypsa} solves this rung's network at objective `{recorded['objective']}`, {sum(recorded['rows'].values())} rows." + diverges = recorded['diverges'] + issue = f'[PyPSA/PyPSA#{diverges["issue"]}](https://github.com/PyPSA/PyPSA/issues/{diverges["issue"]})' + gives = ( + f"raises `{diverges['raises']}` on this rung's network" + if 'raises' in diverges + else f"solves this rung's network at objective `{diverges['objective']}`, {sum(recorded['rows'].values())} rows" + ) + return f'> ✘ {pypsa} {gives}, {issue}. The intended objective is `{recorded["objective"]}`.' + + def reference_block(stem: str) -> str: """A rung's oracle: the recorded solve, then the PyPSA script that builds its network.""" - recorded = RECORDED[stem] - rows = sum(recorded['rows'].values()) return ( - f"> ✔ `pypsa {recorded['pypsa']}` solves this rung's network at objective " - f'`{recorded["objective"]}`, {rows} rows.\n' + f'{_banner(RECORDED[stem])}\n' '\n' '
\n' 'The network, as PyPSA code\n' From 80a378e66a339786608d25e470c1d82db3efaa5a Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:35:16 +0200 Subject: [PATCH 45/75] docs(pypsa): a transmission cost or volume limit holds in every scenario Rungs 52 and 53 record PyPSA/PyPSA#1939, where PyPSA 1.3.0 drops both rows. --- docs/examples/pypsa.md | 197 +++++++++++++++++- examples/references/pypsa/references.json | 68 ++++++ .../pypsa/rung_52_scenario_cost_limit.py | 54 +++++ .../rung_53_scenario_period_volume_limit.py | 73 +++++++ 4 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 examples/references/pypsa/rung_52_scenario_cost_limit.py create mode 100644 examples/references/pypsa/rung_53_scenario_period_volume_limit.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 8d9c8cd1..f50aaffb 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -3389,8 +3389,8 @@ network and the calm values in both futures, PyPSA solves to | PyPSA | status | note | | --- | --- | --- | | [`primary_energy`](#primary_energy), [`operational_limit`](#operational_limit), [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) with a constant and sense per scenario | done | `GlobalConstraint_constant` and `GlobalConstraint_sense` over `scenario` | -| `transmission_expansion_cost_limit` on a network with scenarios | out | PyPSA `1.3.0` builds no row: it matches extendable names against a table indexed by scenario and name, and finds none (`global_constraints.py:916`). The file builds the row per scenario | -| `transmission_volume_expansion_limit` on a network with scenarios and `multi_investment_periods` | out | PyPSA `1.3.0` builds no row: the active-asset filter reindexes a table indexed by scenario and name by the names alone, and keeps none (`global_constraints.py:828`, `descriptors.py:261-263`). The file builds the row per scenario | +| `transmission_expansion_cost_limit` on a network with scenarios | diverges | rung 52, [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939) | +| `transmission_volume_expansion_limit` on a network with scenarios and `multi_investment_periods` | diverges | rung 53, [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939) | | a `carrier_attribute` or `investment_period` per scenario | done | PyPSA reads both per scenario (`global_constraints.py:797-802`); the weights and `GlobalConstraint_counts_snapshot` span `scenario` | @@ -4125,6 +4125,199 @@ def oracle():
+### Rung 52 — a transmission cost limit per scenario + +`n.set_scenarios(...)` with a `transmission_expansion_cost_limit` row. The +file builds the row in every scenario, as it builds every global constraint. +PyPSA `1.3.0` builds no row: it matches the extendable names against a table +indexed by scenario and name, and finds none (`global_constraints.py:916`, +[PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939)). + +The rung adds an extendable DC link to the spine under a cost limit of `150`, +so the link builds `15`. The two futures are identical. The oracle is the same +network without scenarios, which PyPSA solves with the row to `15630.0`. With +scenarios, PyPSA solves to `11890.0`, the objective of the network without the +row. + +| PyPSA | status | note | +| --- | --- | --- | +| [`transmission_expansion_cost_limit`](#transmission_expansion_cost_limit) on a network with scenarios | diverges | [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939); the row per scenario | + + +> ✘ `pypsa 1.3.0` solves this rung's network at objective `11890.0`, 84 rows, [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939). The intended objective is `15630.0`. + +
+The network, as PyPSA code + +`rung_52_scenario_cost_limit.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 52: a `transmission_expansion_cost_limit` row holds in every scenario. + +PyPSA 1.3.0 builds no such row on a network with scenarios (PyPSA/PyPSA#1939). +The two futures are identical, so the oracle is the same network without +scenarios, which PyPSA solves with the row. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1939 + + +def network(): + """The spine plus an extendable DC link whose build a cost limit of 150 caps.""" + n = spine.build() + n.add('Carrier', 'DC') + n.add( + 'Link', + 'hvdc52', + bus0='north', + bus1='south', + carrier='DC', + p_nom_extendable=True, + p_nom_max=100, + capital_cost=10, + ) + n.add('Load', 'port52', bus='south', p_set=40) + n.add( + 'GlobalConstraint', + 'cost52', + type='transmission_expansion_cost_limit', + carrier_attribute='DC', + sense='<=', + constant=150, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + +### Rung 53 — a transmission volume limit per scenario and period + +`n.set_scenarios(...)` and `n.optimize(multi_investment_periods=True)` with a +`transmission_volume_expansion_limit` row. The file builds the row in every +scenario. PyPSA `1.3.0` builds no row: the active-asset filter reindexes a table +indexed by scenario and name by the names alone, and keeps none +(`global_constraints.py:828`, `descriptors.py:263`, +[PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939)). Without +periods, PyPSA builds the row (rung 40). + +The rung is two periods with an extendable line of length `3` under a volume +limit of `60`, so the line builds `20`. The two futures are identical. The +oracle is the same network without scenarios, which PyPSA solves with the row to +`15005.0`. With scenarios, PyPSA solves to `1465.0`, the objective of the +network without the row. + +| PyPSA | status | note | +| --- | --- | --- | +| [`transmission_volume_expansion_limit`](#transmission_volume_expansion_limit) on a network with scenarios and `multi_investment_periods` | diverges | [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939); the row per scenario | + + +> ✘ `pypsa 1.3.0` solves this rung's network at objective `1465.0`, 68 rows, [PyPSA/PyPSA#1939](https://github.com/PyPSA/PyPSA/issues/1939). The intended objective is `15005.0`. + +
+The network, as PyPSA code + +`rung_53_scenario_period_volume_limit.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 53: a `transmission_volume_expansion_limit` row holds in every scenario under `multi_investment_periods`. + +PyPSA 1.3.0 builds no such row on a network with scenarios and investment +periods (PyPSA/PyPSA#1939). The two futures are identical, so the oracle is the +same network without scenarios, which PyPSA solves with the row. +""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +ISSUE = 1939 +OPTIMIZE = {'multi_investment_periods': True} + + +def network(): + """Two periods, a cheap unit behind an extendable line whose volume a limit of 60 caps.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', ['hill', 'town']) + n.add('Carrier', 'AC') + n.add('Generator', 'hydro53', bus='hill', p_nom=100, marginal_cost=5) + n.add('Generator', 'diesel53', bus='town', p_nom=100, marginal_cost=90) + n.add( + 'Line', + 'tie53', + bus0='hill', + bus1='town', + x=0.1, + carrier='AC', + length=3, + s_nom_extendable=True, + s_nom_max=100, + capital_cost=1, + build_year=2020, + lifetime=30, + ) + n.add('Load', 'town_load', bus='town', p_set=[40, 50, 60, 45]) + n.add( + 'GlobalConstraint', + 'volume53', + type='transmission_volume_expansion_limit', + carrier_attribute='AC', + sense='<=', + constant=60, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 13de643e..9db4910d 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1899,5 +1899,73 @@ "Generator-fix-p-lower": 4, "Generator-fix-p-upper": 4 } + }, + "rung_52_scenario_cost_limit": { + "columns": { + "Generator-p": 16, + "Link-p": 16, + "Link-p_nom": 1 + }, + "diverges": { + "issue": 1939, + "objective": 11890.0 + }, + "global_constraints": { + "('calm', 'cost52')": { + "sense": "<=", + "type": "transmission_expansion_cost_limit" + }, + "('stormy', 'cost52')": { + "sense": "<=", + "type": "transmission_expansion_cost_limit" + } + }, + "objective": 15630.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 16, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Link-ext-p-lower": 8, + "Link-ext-p-upper": 8, + "Link-ext-p_nom-lower": 2, + "Link-ext-p_nom-upper": 2, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8 + } + }, + "rung_53_scenario_period_volume_limit": { + "columns": { + "Generator-p": 16, + "Line-s": 8, + "Line-s_nom": 1 + }, + "diverges": { + "issue": 1939, + "objective": 1465.0 + }, + "global_constraints": { + "('calm', 'volume53')": { + "sense": "<=", + "type": "transmission_volume_expansion_limit" + }, + "('stormy', 'volume53')": { + "sense": "<=", + "type": "transmission_volume_expansion_limit" + } + }, + "objective": 15005.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 16, + "Generator-fix-p-lower": 16, + "Generator-fix-p-upper": 16, + "Line-ext-s-lower": 8, + "Line-ext-s-upper": 8, + "Line-ext-s_nom-lower": 2, + "Line-ext-s_nom-upper": 2 + } } } diff --git a/examples/references/pypsa/rung_52_scenario_cost_limit.py b/examples/references/pypsa/rung_52_scenario_cost_limit.py new file mode 100644 index 00000000..74820c40 --- /dev/null +++ b/examples/references/pypsa/rung_52_scenario_cost_limit.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 52: a `transmission_expansion_cost_limit` row holds in every scenario. + +PyPSA 1.3.0 builds no such row on a network with scenarios (PyPSA/PyPSA#1939). +The two futures are identical, so the oracle is the same network without +scenarios, which PyPSA solves with the row. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1939 + + +def network(): + """The spine plus an extendable DC link whose build a cost limit of 150 caps.""" + n = spine.build() + n.add('Carrier', 'DC') + n.add( + 'Link', + 'hvdc52', + bus0='north', + bus1='south', + carrier='DC', + p_nom_extendable=True, + p_nom_max=100, + capital_cost=10, + ) + n.add('Load', 'port52', bus='south', p_set=40) + n.add( + 'GlobalConstraint', + 'cost52', + type='transmission_expansion_cost_limit', + carrier_attribute='DC', + sense='<=', + constant=150, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] diff --git a/examples/references/pypsa/rung_53_scenario_period_volume_limit.py b/examples/references/pypsa/rung_53_scenario_period_volume_limit.py new file mode 100644 index 00000000..afe570d0 --- /dev/null +++ b/examples/references/pypsa/rung_53_scenario_period_volume_limit.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 53: a `transmission_volume_expansion_limit` row holds in every scenario under `multi_investment_periods`. + +PyPSA 1.3.0 builds no such row on a network with scenarios and investment +periods (PyPSA/PyPSA#1939). The two futures are identical, so the oracle is the +same network without scenarios, which PyPSA solves with the row. +""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +ISSUE = 1939 +OPTIMIZE = {'multi_investment_periods': True} + + +def network(): + """Two periods, a cheap unit behind an extendable line whose volume a limit of 60 caps.""" + import pypsa + + n = pypsa.Network() + n.snapshots = pd.MultiIndex.from_tuples( + [(2020, datetime(2020, 1, 1, t)) for t in range(2)] + [(2030, datetime(2030, 1, 1, t)) for t in range(2)] + ) + n.investment_periods = [2020, 2030] + n.investment_period_weightings['objective'] = [1.0, 0.5] + n.investment_period_weightings['years'] = [10.0, 10.0] + n.snapshot_weightings['objective'] = [2.0, 1.5, 2.5, 2.0] + n.add('Bus', ['hill', 'town']) + n.add('Carrier', 'AC') + n.add('Generator', 'hydro53', bus='hill', p_nom=100, marginal_cost=5) + n.add('Generator', 'diesel53', bus='town', p_nom=100, marginal_cost=90) + n.add( + 'Line', + 'tie53', + bus0='hill', + bus1='town', + x=0.1, + carrier='AC', + length=3, + s_nom_extendable=True, + s_nom_max=100, + capital_cost=1, + build_year=2020, + lifetime=30, + ) + n.add('Load', 'town_load', bus='town', p_set=[40, 50, 60, 45]) + n.add( + 'GlobalConstraint', + 'volume53', + type='transmission_volume_expansion_limit', + carrier_attribute='AC', + sense='<=', + constant=60, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] From f3e42112dc965d5d61aa5d49da5b27e3e2231298 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:35:35 +0200 Subject: [PATCH 46/75] docs(pypsa): each scenario delays a link's and a process's flow by its own delay Rung 54 records PyPSA/PyPSA#1941, where PyPSA 1.3.0 delivers the flow twice. --- docs/examples/pypsa.md | 117 ++++++++++++++++-- examples/pypsa.yaml | 22 ++-- examples/references/pypsa/references.json | 24 ++++ .../pypsa/rung_54_scenario_delay.py | 65 ++++++++++ 4 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 examples/references/pypsa/rung_54_scenario_delay.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index f50aaffb..58208f8e 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -3479,7 +3479,7 @@ calm efficiency in both, to `16992.0`; with both calm values in both, to | PyPSA | status | note | | --- | --- | --- | | [objective](#objective), [`Bus-nodal_balance`](#bus-nodal_balance) with a cost and an efficiency per scenario | done | `Generator_marginal_cost` and `Link_efficiency` over `scenario` | -| a link `delay` or `cyclic_delay` that differs by scenario | out | PyPSA `1.3.0` groups the ports by delay over all scenarios and shifts each group in every scenario, so a delay of `0` in one future and `1` in the other solves below both uniform networks (`constraints.py:1269`). The file holds one delay for every scenario | +| a link `delay` or `cyclic_delay` that differs by scenario | diverges | rung 54, [PyPSA/PyPSA#1941](https://github.com/PyPSA/PyPSA/issues/1941) | | a transformer in a cycle on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the transformers of a cycle by name from a table indexed by scenario and name (`constraints.py:1654`). The file holds one phase shift for every scenario | | a committable component on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the status by snapshot and name where the first dimension is the scenario (`constraints.py:1872`, `:1942`). The file builds the rows per scenario | | [`{c}-p_nom_set`](#generator-p_nom_set) on a network with scenarios | out | PyPSA `1.3.0` fails: it reindexes the build by a table indexed by scenario and name (`constraints.py:1708`). The file builds the row per scenario | @@ -4318,6 +4318,109 @@ def oracle(): +### Rung 54 — a delay per scenario + +`n.set_scenarios(...)` with a link `delay` and a process `delay1` that differ +by scenario. The file states `Link_output_delay`, `Link_output_cyclic_delay`, +`Process_output_delay` and `Process_output_cyclic_delay` over `scenario`, so +each future shifts a port's flow by its own delay. The shifted flow already +spans `scenario`, so the offset may too. PyPSA `1.3.0` groups the ports by +delay over all scenarios and shifts each group in every scenario, so a port +whose delay differs by scenario delivers its flow once per group +(`constraints.py:1269-1276`, +[PyPSA/PyPSA#1941](https://github.com/PyPSA/PyPSA/issues/1941)). A plain run +feeds one scenario, and the rows collapse to the standard ones. + +The rung is a capped source feeding two sinks, one through a link and one +through a process. The calm future delivers at once. The stormy one delivers a +snapshot late, cyclically on the link and with the first snapshot lost on the +process. Nothing is extendable, so the futures do not interact. The oracle is +each future solved alone, `7650.0` calm and `11500.0` stormy, weighted `0.6` +and `0.4`: `9190.0`. PyPSA solves to `9300.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Link_output_arrival`](#link_output_arrival), [`Process_output_arrival`](#process_output_arrival) with a `delay` or `cyclic_delay` that differs by scenario | diverges | [PyPSA/PyPSA#1941](https://github.com/PyPSA/PyPSA/issues/1941); the delays span `scenario` | + + +> ✘ `pypsa 1.3.0` solves this rung's network at objective `9300.0`, 104 rows, [PyPSA/PyPSA#1941](https://github.com/PyPSA/PyPSA/issues/1941). The intended objective is `9190.0`. + +
+The network, as PyPSA code + +`rung_54_scenario_delay.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 54: each scenario delays a link's and a process's flow by its own `delay`. + +PyPSA 1.3.0 groups the ports by delay over all scenarios and shifts every group +in every scenario, so a port whose delay differs by scenario delivers twice +(PyPSA/PyPSA#1941). Nothing is extendable, so the scenarios do not interact: the +oracle is each future solved alone, weighted by its probability. +""" + +from __future__ import annotations + +from datetime import datetime + +ISSUE = 1941 + +#: The `generators` weighting is uniform, as on rung 16, so a delay of `n` is a +#: shift of exactly `n` positions. The `objective` column stays non-uniform. +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0], 'generators': [1.0] * 4} +DEMAND = [20.0, 35.0, 5.0, 30.0] +SCENARIOS = {'calm': 0.6, 'stormy': 0.4} + +#: each future's own delay, per port: the calm one delivers at once, the stormy one a snapshot late +DELAYS = { + 'calm': {'pipe54': {'delay': 0, 'cyclic_delay': True}, 'conv54': {'delay1': 0, 'cyclic_delay1': True}}, + 'stormy': {'pipe54': {'delay': 1, 'cyclic_delay': True}, 'conv54': {'delay1': 1, 'cyclic_delay1': False}}, +} + + +def network(delays: dict[str, dict[str, object]]): + """A capped source feeding two sinks, one through a link and one through a process, with the given delays.""" + import pypsa + + n = pypsa.Network() + n.set_snapshots([datetime(2015, 1, 1, hour) for hour in range(4)]) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', ['source', 'sink_link', 'sink_process']) + n.add('Generator', 'spring54', bus='source', p_nom=50, marginal_cost=5) + n.add('Generator', 'backup_link54', bus='sink_link', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_process54', bus='sink_process', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe54', bus0='source', bus1='sink_link', p_nom=30, **delays['pipe54']) + n.add('Process', 'conv54', bus0='source', bus1='sink_process', p_nom=30, **delays['conv54']) + n.add('Load', 'load_link54', bus='sink_link', p_set=DEMAND) + n.add('Load', 'load_process54', bus='sink_process', p_set=DEMAND) + return n + + +def build(): + """The network over two futures, each with its own delays.""" + n = network(DELAYS['calm']) + n.set_scenarios(SCENARIOS) + for scenario, ports in DELAYS.items(): + for name, values in ports.items(): + component = n.c.links if name == 'pipe54' else n.c.processes + for column, value in values.items(): + component.static.loc[(scenario, name), column] = value + return n + + +def oracle(): + """Each future alone, with its own delays, weighted by its probability.""" + return [(weight, network(DELAYS[scenario])) for scenario, weight in SCENARIOS.items()] +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4418,8 +4521,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | | $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | | $`\eta`$ | `Link_efficiency` over $`\Xi \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | -| $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. One for every scenario: PyPSA groups the ports by delay over all scenarios and shifts each group in every one (`constraints.py:1269`) | -| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. One for every scenario, as the delay | +| $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\Xi \times \mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. Each scenario takes its own. PyPSA `1.3.0` groups the ports by delay over all scenarios and shifts each group in every one, so a delay that differs by scenario delivers the flow twice (`constraints.py:1269-1276`, PyPSA/PyPSA\#1941) | +| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\Xi \times \mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | @@ -4448,8 +4551,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | | $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | | $`\alpha`$ | `Process_rate` over $`\Xi \times \mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | -| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once. One for every scenario, as a link's | -| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. One for every scenario, as the delay | +| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\Xi \times \mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once. Each scenario takes its own, as a link's | +| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\Xi \times \mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | | $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | | $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | @@ -9447,7 +9550,7 @@ Link_output_arrival: ``` ```math -\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{if } \mathrm{cyc}^{f}_{o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{if } \mathrm{cyc}^{f}_{\xi,o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} ``` ### `Process_output_arrival` @@ -9470,7 +9573,7 @@ Process_output_arrival: ``` ```math -\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{if } \mathrm{cyc}^{z}_{r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{if } \mathrm{cyc}^{z}_{\xi,r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} ``` ### `GlobalConstraint_energy_weight` diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 55a73a03..ee3127d4 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -236,17 +236,19 @@ parameters: snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. - One for every scenario: PyPSA groups the ports by delay over all scenarios - and shifts each group in every one (`constraints.py:1269`) - dims: [link_output] + Each scenario takes its own. PyPSA `1.3.0` groups the ports by delay over + all scenarios and shifts each group in every one, so a delay that differs + by scenario delivers the flow twice (`constraints.py:1269-1276`, + PyPSA/PyPSA#1941) + dims: [scenario, link_output] dtype: int Link_output_cyclic_delay: description: >- whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, - the flow still in transit at each period's first snapshots is lost. One - for every scenario, as the delay - dims: [link_output] + the flow still in transit at each period's first snapshots is lost. Each + scenario takes its own, as the delay + dims: [scenario, link_output] dtype: bool Link_marginal_cost: description: cost of one unit of flow @@ -378,16 +380,16 @@ parameters: snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that - transfers at once. One for every scenario, as a link's - dims: [process_output] + transfers at once. Each scenario takes its own, as a link's + dims: [scenario, process_output] dtype: int Process_output_cyclic_delay: description: >- whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. - One for every scenario, as the delay - dims: [process_output] + Each scenario takes its own, as the delay + dims: [scenario, process_output] dtype: bool Process_marginal_cost: description: cost of one unit of internal power diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 9db4910d..a1ee7d04 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1967,5 +1967,29 @@ "Line-ext-s_nom-lower": 2, "Line-ext-s_nom-upper": 2 } + }, + "rung_54_scenario_delay": { + "columns": { + "Generator-p": 24, + "Link-p": 8, + "Process-p": 8 + }, + "diverges": { + "issue": 1941, + "objective": 9300.0 + }, + "global_constraints": {}, + "objective": 9190.0, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 24, + "Generator-fix-p-lower": 24, + "Generator-fix-p-upper": 24, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8, + "Process-fix-p-lower": 8, + "Process-fix-p-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_54_scenario_delay.py b/examples/references/pypsa/rung_54_scenario_delay.py new file mode 100644 index 00000000..ac2bbafa --- /dev/null +++ b/examples/references/pypsa/rung_54_scenario_delay.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 54: each scenario delays a link's and a process's flow by its own `delay`. + +PyPSA 1.3.0 groups the ports by delay over all scenarios and shifts every group +in every scenario, so a port whose delay differs by scenario delivers twice +(PyPSA/PyPSA#1941). Nothing is extendable, so the scenarios do not interact: the +oracle is each future solved alone, weighted by its probability. +""" + +from __future__ import annotations + +from datetime import datetime + +ISSUE = 1941 + +#: The `generators` weighting is uniform, as on rung 16, so a delay of `n` is a +#: shift of exactly `n` positions. The `objective` column stays non-uniform. +WEIGHTINGS = {'objective': [2.0, 1.5, 2.5, 3.0], 'generators': [1.0] * 4} +DEMAND = [20.0, 35.0, 5.0, 30.0] +SCENARIOS = {'calm': 0.6, 'stormy': 0.4} + +#: each future's own delay, per port: the calm one delivers at once, the stormy one a snapshot late +DELAYS = { + 'calm': {'pipe54': {'delay': 0, 'cyclic_delay': True}, 'conv54': {'delay1': 0, 'cyclic_delay1': True}}, + 'stormy': {'pipe54': {'delay': 1, 'cyclic_delay': True}, 'conv54': {'delay1': 1, 'cyclic_delay1': False}}, +} + + +def network(delays: dict[str, dict[str, object]]): + """A capped source feeding two sinks, one through a link and one through a process, with the given delays.""" + import pypsa + + n = pypsa.Network() + n.set_snapshots([datetime(2015, 1, 1, hour) for hour in range(4)]) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', ['source', 'sink_link', 'sink_process']) + n.add('Generator', 'spring54', bus='source', p_nom=50, marginal_cost=5) + n.add('Generator', 'backup_link54', bus='sink_link', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_process54', bus='sink_process', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe54', bus0='source', bus1='sink_link', p_nom=30, **delays['pipe54']) + n.add('Process', 'conv54', bus0='source', bus1='sink_process', p_nom=30, **delays['conv54']) + n.add('Load', 'load_link54', bus='sink_link', p_set=DEMAND) + n.add('Load', 'load_process54', bus='sink_process', p_set=DEMAND) + return n + + +def build(): + """The network over two futures, each with its own delays.""" + n = network(DELAYS['calm']) + n.set_scenarios(SCENARIOS) + for scenario, ports in DELAYS.items(): + for name, values in ports.items(): + component = n.c.links if name == 'pipe54' else n.c.processes + for column, value in values.items(): + component.static.loc[(scenario, name), column] = value + return n + + +def oracle(): + """Each future alone, with its own delays, weighted by its probability.""" + return [(weight, network(DELAYS[scenario])) for scenario, weight in SCENARIOS.items()] From 720275cd39f5ac430e6d343283f7b2c02ec62dee Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:35:53 +0200 Subject: [PATCH 47/75] docs(pypsa): transformer cycles, security-constrained runs, fixed builds and committable units hold in every scenario Rungs 55 to 58 record PyPSA/PyPSA#1942 and PyPSA/PyPSA#1913, where PyPSA 1.3.0 raises. --- docs/examples/pypsa.md | 310 +++++++++++++++++- examples/references/pypsa/references.json | 32 ++ .../rung_55_scenario_transformer_cycle.py | 40 +++ .../rung_56_scenario_security_constrained.py | 41 +++ .../pypsa/rung_57_scenario_nom_set.py | 43 +++ .../pypsa/rung_58_scenario_committable.py | 47 +++ 6 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 examples/references/pypsa/rung_55_scenario_transformer_cycle.py create mode 100644 examples/references/pypsa/rung_56_scenario_security_constrained.py create mode 100644 examples/references/pypsa/rung_57_scenario_nom_set.py create mode 100644 examples/references/pypsa/rung_58_scenario_committable.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 58208f8e..edc74ac1 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -2545,7 +2545,7 @@ bind, in `Transformer-fix-s-lower` against a line outage and in | [`Line-fix-s-*-security-for-{c}-outage-in-sub-network-{n}`](#line-fix-s-lower-security-for-c-outage-in-sub-network-n), [`Line-ext-s-*-security-…`](#line-ext-s-lower-security-for-c-outage-in-sub-network-n) | split | PyPSA names a row per outaged component and sub-network; one block over the `outage` axis | | [`Transformer-fix-s-*-security-…`](#transformer-fix-s-lower-security-for-c-outage-in-sub-network-n), [`Transformer-ext-s-*-security-…`](#transformer-ext-s-lower-security-for-c-outage-in-sub-network-n) | split | the same for a transformer | | a branch not active in a period | done | PyPSA keeps the copy with that branch's flow dropped, so the file reads its flow as zero there; a copy left with no variable is not built here, where linopy counts it; no rung records it | -| a security-constrained run over scenarios | out | PyPSA `1.3.0` raises, see [Refusals](#refusals) | +| a security-constrained run over scenarios | diverges | rung 56, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942) | | `transmission_losses`, `linearized_unit_commitment` in a security-constrained run | done | PyPSA builds neither, so the copies carry no loss term and data prep feeds `transmission_losses` false; no rung, since rung 30 is lossless | @@ -3480,9 +3480,9 @@ calm efficiency in both, to `16992.0`; with both calm values in both, to | --- | --- | --- | | [objective](#objective), [`Bus-nodal_balance`](#bus-nodal_balance) with a cost and an efficiency per scenario | done | `Generator_marginal_cost` and `Link_efficiency` over `scenario` | | a link `delay` or `cyclic_delay` that differs by scenario | diverges | rung 54, [PyPSA/PyPSA#1941](https://github.com/PyPSA/PyPSA/issues/1941) | -| a transformer in a cycle on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the transformers of a cycle by name from a table indexed by scenario and name (`constraints.py:1654`). The file holds one phase shift for every scenario | -| a committable component on a network with scenarios | out | PyPSA `1.3.0` fails: it selects the status by snapshot and name where the first dimension is the scenario (`constraints.py:1872`, `:1942`). The file builds the rows per scenario | -| [`{c}-p_nom_set`](#generator-p_nom_set) on a network with scenarios | out | PyPSA `1.3.0` fails: it reindexes the build by a table indexed by scenario and name (`constraints.py:1708`). The file builds the row per scenario | +| a transformer in a cycle on a network with scenarios | diverges | rung 55, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942) | +| a committable component on a network with scenarios | diverges | rung 58, [PyPSA/PyPSA#1913](https://github.com/PyPSA/PyPSA/issues/1913) | +| [`{c}-p_nom_set`](#generator-p_nom_set) on a network with scenarios | diverges | rung 57, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942) | > ✔ `pypsa 1.3.0` solves this rung's network at objective `17964.0`, 96 rows. @@ -4421,6 +4421,306 @@ def oracle(): +### Rung 55 — a transformer cycle per scenario + +`n.set_scenarios(...)` with two transformers in parallel, a cycle. The file +builds the Kirchhoff voltage row in every scenario. PyPSA `1.3.0` raises +`KeyError`: it selects the transformers of a cycle by name from a table indexed +by scenario and name (`constraints.py:1654`, +[PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942)). + +The rung adds two transformers of reactance `0.1` and `0.2`, each rated `30`, +to the spine. The row splits the flow two to one, so the first caps the pair at +`45`. The two futures are identical. The oracle is the same network without +scenarios, which PyPSA solves to `13105.0`. One transformer rated `60` solves to +`11705.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Kirchhoff-Voltage-Law`](#kirchhoff-voltage-law) with a transformer on a network with scenarios | diverges | [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942); the row per scenario. The file holds one phase shift for every scenario | + + +> ✘ `pypsa 1.3.0` raises `KeyError` on this rung's network, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942). The intended objective is `13105.0`. + +
+The network, as PyPSA code + +`rung_55_scenario_transformer_cycle.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 55: a cycle of two transformers takes its Kirchhoff voltage row in every scenario. + +PyPSA 1.3.0 raises on a transformer in a cycle on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 + + +def network(): + """The spine plus two parallel transformers of unequal reactance, so the one that takes more flow caps the pair.""" + n = spine.build() + n.add('Bus', ['a', 'b']) + n.add('Generator', 'hydro55', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel55', bus='b', p_nom=100, marginal_cost=50) + n.add('Load', 'town55', bus='b', p_set=[50, 40, 55, 45]) + n.add('Transformer', 't55', bus0='a', bus1='b', x=0.1, s_nom=30) + n.add('Transformer', 't55_2', bus0='a', bus1='b', x=0.2, s_nom=30) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + +### Rung 56 — a security-constrained run per scenario + +`n.optimize.optimize_security_constrained(...)` on a network with scenarios. +The file builds the outage copies in every scenario. PyPSA `1.3.0` raises +`ValueError`. With outages named as a list, it finds none of them in the +network (`abstract.py:427`). With no outages named, it fails to intersect the +branches (`abstract.py:445`, +[PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942)). With outages +named as `(component, name)` pairs, it builds no copy and solves without them. + +The rung adds two parallel lines rated `30` and `35` to the spine, and outages +each. Each line must carry the whole import alone, so the import falls to `30`. +The two futures are identical. The oracle is the same network without +scenarios, which PyPSA solves to `18205.0`. A plain `n.optimize()` solves to +`11705.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`Line-fix-s-*-security-for-{c}-outage-in-sub-network-{n}`](#line-fix-s-lower-security-for-c-outage-in-sub-network-n) on a network with scenarios | diverges | [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942); the copies per scenario | + + +> ✘ `pypsa 1.3.0` raises `ValueError` on this rung's network, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942). The intended objective is `18205.0`. + +
+The network, as PyPSA code + +`rung_56_scenario_security_constrained.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 56: a security-constrained run over scenarios copies its rows into every scenario. + +PyPSA 1.3.0 raises on a security-constrained run on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 +BRANCH_OUTAGES = ['l56', 'l56_2'] + + +def network(): + """The spine plus two parallel lines, so each must carry the whole import alone when the other is out.""" + n = spine.build() + n.add('Bus', ['a', 'b']) + n.add('Generator', 'hydro56', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel56', bus='b', p_nom=100, marginal_cost=50) + n.add('Load', 'town56', bus='b', p_set=[50, 40, 55, 45]) + n.add('Line', 'l56', bus0='a', bus1='b', x=0.1, s_nom=30) + n.add('Line', 'l56_2', bus0='a', bus1='b', x=0.1, s_nom=35) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + +### Rung 57 — a fixed build per scenario + +`n.set_scenarios(...)` with `p_nom_set` on an extendable unit. The file builds +`Generator-p_nom_set` in every scenario. PyPSA `1.3.0` raises `TypeError`: it +renames the scenario-and-name index of the set build with one name +(`constraints.py:1708`, +[PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942)). The same holds +for every `*_nom_set`. + +The rung adds a cheap extendable wind unit to the spine, pinned to `20`. The +two futures are identical. The oracle is the same network without scenarios, +which PyPSA solves to `4800.0`. Without `p_nom_set`, the unit builds `67` and +the network solves to `335.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| [`{c}-p_nom_set`](#generator-p_nom_set) on a network with scenarios | diverges | [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942); the row per scenario | + + +> ✘ `pypsa 1.3.0` raises `TypeError` on this rung's network, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942). The intended objective is `4800.0`. + +
+The network, as PyPSA code + +`rung_57_scenario_nom_set.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 57: `p_nom_set` pins an extendable build on a network with scenarios. + +PyPSA 1.3.0 raises on any `*_nom_set` on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 + + +def network(): + """The spine plus a cheap extendable wind unit whose build is pinned below what it would choose.""" + n = spine.build() + n.add( + 'Generator', + 'wind57', + bus='south', + p_nom_extendable=True, + p_nom_max=100, + capital_cost=5, + p_nom_set=20, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + +### Rung 58 — a committable unit per scenario + +`n.set_scenarios(...)` with a committable unit. The file builds the status +rows in every scenario. PyPSA `1.3.0` raises `KeyError`: it selects the status +by snapshot and name where the first dimension is the scenario +(`constraints.py:1872`, +[PyPSA/PyPSA#1913](https://github.com/PyPSA/PyPSA/issues/1913)). + +The rung adds a cheap committable unit to the spine that cannot run below `40` +% of its build, with a start-up cost of `100`. The two futures are identical. +The oracle is the same network without scenarios, which PyPSA solves to +`7430.0`. The same unit, not committable, solves to `7330.0`. + +| PyPSA | status | note | +| --- | --- | --- | +| a committable component on a network with scenarios | diverges | [PyPSA/PyPSA#1913](https://github.com/PyPSA/PyPSA/issues/1913); the rows per scenario | + + +> ✘ `pypsa 1.3.0` raises `KeyError` on this rung's network, [PyPSA/PyPSA#1913](https://github.com/PyPSA/PyPSA/issues/1913). The intended objective is `7430.0`. + +
+The network, as PyPSA code + +`rung_58_scenario_committable.py` + +```python +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 58: a committable unit takes its status rows in every scenario. + +PyPSA 1.3.0 raises on a committable component on a network with scenarios +(PyPSA/PyPSA#1913). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1913 + + +def network(): + """The spine plus a cheap committable unit that cannot run below 40 % of its build.""" + n = spine.build() + n.add( + 'Generator', + 'uc58', + bus='north', + committable=True, + p_nom=50, + marginal_cost=5, + p_min_pu=0.4, + min_up_time=2, + up_time_before=0, + start_up_cost=100, + ) + n.add('Load', 'swing58', bus='north', p_set=[5, 45, 45, 10]) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] +``` + +
+ + ## Refusals Where PyPSA refuses to build, parity means refusing too. None is a language @@ -4440,7 +4740,7 @@ where it should live — language, data prep, or harness — is one open questio | `UnboundLocalError`, `global_constraints.py:375`, `:602` | a `primary_energy` or `operational_limit` row that names an `investment_period` without `multi_investment_periods` | data prep, at `GlobalConstraint_counts_snapshot` | | | `ValueError`, `constraints.py:2411`, `:2518` | an extendable lossy branch with `s_nom_max = inf`, either mode | data prep, at `Line_loss_max` and `Transformer_loss_max` | X4 | | `RuntimeError`, `constraints.py:2561` | the secant loop passing `max_segments` | data prep, at the `segment` axis | X4 | -| `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused | | +| `ValueError`, `abstract.py:427`, `:445` | a security-constrained run over scenarios | rows per scenario, not refused: a PyPSA bug, [PyPSA/PyPSA#1942](https://github.com/PyPSA/PyPSA/issues/1942), rung 56 | | | `NotImplementedError`, `global_constraints.py:66-68` | a `tech_capacity_expansion_limit` row on a network with scenarios | assumed where there is more than one scenario: [`GlobalConstraint_tech_capacity_expansion_limit_without_scenarios`](#globalconstraint_tech_capacity_expansion_limit_without_scenarios). The file cannot tell one scenario from none, which PyPSA also refuses | | | `ConsistencyError`, `consistency.py:1506-1560` | a maintainable component whose `maintenance_duration` or `maintenance_events` is not positive, whose events do not fit the weighted horizon, or that is extendable with `p_nom_max = inf` | assumed: [`Generator_maintenance_events_positive`](#generator_maintenance_events_positive), [`-duration_positive`](#generator_maintenance_duration_positive), [`-duration_fits_the_horizon`](#generator_maintenance_duration_fits_the_horizon), [`-events_fit_the_horizon`](#generator_maintenance_events_fit_the_horizon), [`-build_cap_is_finite`](#generator_maintenance_build_cap_is_finite), and the `Link` and `Process` ones | | | nothing; HiGHS refuses the model, `constraints.py:500-503` | a fixed modular committable maintainable build, whose module count `p_nom_max / p_nom_mod` is infinite | assumed: [`Generator_maintenance_module_count_is_finite`](#generator_maintenance_module_count_is_finite), and the `Link` and `Process` ones | | diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index a1ee7d04..e94d92fe 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -1991,5 +1991,37 @@ "Process-fix-p-lower": 8, "Process-fix-p-upper": 8 } + }, + "rung_55_scenario_transformer_cycle": { + "diverges": { + "issue": 1942, + "raises": "KeyError" + }, + "objective": 13105.0, + "pypsa": "1.3.0" + }, + "rung_56_scenario_security_constrained": { + "diverges": { + "issue": 1942, + "raises": "ValueError" + }, + "objective": 18205.0, + "pypsa": "1.3.0" + }, + "rung_57_scenario_nom_set": { + "diverges": { + "issue": 1942, + "raises": "TypeError" + }, + "objective": 4800.0, + "pypsa": "1.3.0" + }, + "rung_58_scenario_committable": { + "diverges": { + "issue": 1913, + "raises": "KeyError" + }, + "objective": 7430.0, + "pypsa": "1.3.0" } } diff --git a/examples/references/pypsa/rung_55_scenario_transformer_cycle.py b/examples/references/pypsa/rung_55_scenario_transformer_cycle.py new file mode 100644 index 00000000..d2b274b0 --- /dev/null +++ b/examples/references/pypsa/rung_55_scenario_transformer_cycle.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 55: a cycle of two transformers takes its Kirchhoff voltage row in every scenario. + +PyPSA 1.3.0 raises on a transformer in a cycle on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 + + +def network(): + """The spine plus two parallel transformers of unequal reactance, so the one that takes more flow caps the pair.""" + n = spine.build() + n.add('Bus', ['a', 'b']) + n.add('Generator', 'hydro55', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel55', bus='b', p_nom=100, marginal_cost=50) + n.add('Load', 'town55', bus='b', p_set=[50, 40, 55, 45]) + n.add('Transformer', 't55', bus0='a', bus1='b', x=0.1, s_nom=30) + n.add('Transformer', 't55_2', bus0='a', bus1='b', x=0.2, s_nom=30) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] diff --git a/examples/references/pypsa/rung_56_scenario_security_constrained.py b/examples/references/pypsa/rung_56_scenario_security_constrained.py new file mode 100644 index 00000000..9bc487e4 --- /dev/null +++ b/examples/references/pypsa/rung_56_scenario_security_constrained.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 56: a security-constrained run over scenarios copies its rows into every scenario. + +PyPSA 1.3.0 raises on a security-constrained run on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 +BRANCH_OUTAGES = ['l56', 'l56_2'] + + +def network(): + """The spine plus two parallel lines, so each must carry the whole import alone when the other is out.""" + n = spine.build() + n.add('Bus', ['a', 'b']) + n.add('Generator', 'hydro56', bus='a', p_nom=100, marginal_cost=10) + n.add('Generator', 'diesel56', bus='b', p_nom=100, marginal_cost=50) + n.add('Load', 'town56', bus='b', p_set=[50, 40, 55, 45]) + n.add('Line', 'l56', bus0='a', bus1='b', x=0.1, s_nom=30) + n.add('Line', 'l56_2', bus0='a', bus1='b', x=0.1, s_nom=35) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] diff --git a/examples/references/pypsa/rung_57_scenario_nom_set.py b/examples/references/pypsa/rung_57_scenario_nom_set.py new file mode 100644 index 00000000..f221c420 --- /dev/null +++ b/examples/references/pypsa/rung_57_scenario_nom_set.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 57: `p_nom_set` pins an extendable build on a network with scenarios. + +PyPSA 1.3.0 raises on any `*_nom_set` on a network with scenarios +(PyPSA/PyPSA#1942). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1942 + + +def network(): + """The spine plus a cheap extendable wind unit whose build is pinned below what it would choose.""" + n = spine.build() + n.add( + 'Generator', + 'wind57', + bus='south', + p_nom_extendable=True, + p_nom_max=100, + capital_cost=5, + p_nom_set=20, + ) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] diff --git a/examples/references/pypsa/rung_58_scenario_committable.py b/examples/references/pypsa/rung_58_scenario_committable.py new file mode 100644 index 00000000..61a2645a --- /dev/null +++ b/examples/references/pypsa/rung_58_scenario_committable.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 58: a committable unit takes its status rows in every scenario. + +PyPSA 1.3.0 raises on a committable component on a network with scenarios +(PyPSA/PyPSA#1913). The two futures are identical, so the oracle is the same +network without scenarios. +""" + +from __future__ import annotations + +import spine + +ISSUE = 1913 + + +def network(): + """The spine plus a cheap committable unit that cannot run below 40 % of its build.""" + n = spine.build() + n.add( + 'Generator', + 'uc58', + bus='north', + committable=True, + p_nom=50, + marginal_cost=5, + p_min_pu=0.4, + min_up_time=2, + up_time_before=0, + start_up_cost=100, + ) + n.add('Load', 'swing58', bus='north', p_set=[5, 45, 45, 10]) + return n + + +def build(): + """The same network over two identical futures.""" + n = network() + n.set_scenarios({'calm': 0.6, 'stormy': 0.4}) + return n + + +def oracle(): + """The network without scenarios: the futures are identical, so the expected cost is its cost.""" + return [(1.0, network())] From 60c279e559ceaef3a5a8ebdf10e098b7d0a79909 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 25 Sep 2026 13:36:01 +0200 Subject: [PATCH 48/75] docs(pypsa): the first-snapshot ramp refusals link the open PyPSA question --- docs/examples/pypsa.md | 9 ++++++--- examples/pypsa.yaml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index edc74ac1..648e3fd6 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -11068,7 +11068,8 @@ Generator_came_in_running_unless_committable: start-up ramp where another unit of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable unit and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data ``` ```math @@ -11088,7 +11089,8 @@ Link_came_in_running_unless_committable: start-up ramp where another link of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable link and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data ``` ```math @@ -11108,7 +11110,8 @@ Process_came_in_running_unless_committable: start-up ramp where another process of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable process and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data ``` ```math diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index ee3127d4..4c898373 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -3989,7 +3989,8 @@ assumptions: start-up ramp where another unit of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable unit and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data Link_came_in_running_unless_committable: holds: "Link_status_initial == 1" where: "NOT Link_committable AND (Link_ramp_limit_up OR Link_ramp_limit_down)" @@ -4000,7 +4001,8 @@ assumptions: start-up ramp where another link of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable link and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data Process_came_in_running_unless_committable: holds: "Process_status_initial == 1" where: "NOT Process_committable AND (Process_ramp_limit_up OR Process_ramp_limit_down)" @@ -4011,7 +4013,8 @@ assumptions: start-up ramp where another process of the component is committable with a fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents the attribute as read only for a committable process and does not check - it. The spec does not state that row, so it refuses the data + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data objective: sense: minimize From f58c4d163711aca1e6255cf4c3ba444378d97fc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:22:17 +0000 Subject: [PATCH 49/75] feat(language): two files that state the same spec write one text, and canonical --check fails a file that is not in it Spec.to_yaml(canonical=True) writes the normal form: sections in one order, declarations sorted by name, every expression printed from its parsed tree with the terms of a sum and the factors of a product sorted, one term per line. python -m mathspec canonical writes it; --check exits 1 for a file not in the form and --write rewrites it. Squashes #530 and #718 onto main after #721, in its words: a file states a spec. Also normalises a named expression written on one line, which the form passed through as written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 1 + docs/howto/compare.md | 86 ++++++ docs/reference/reading.md | 47 ++++ mkdocs.yml | 1 + src/mathspec/__main__.py | 65 ++++- src/mathspec/canonical.py | 266 ++++++++++++++++++ src/mathspec/spec.py | 18 +- tests/test_canonical.py | 494 ++++++++++++++++++++++++++++++++++ tests/test_reading_page.py | 2 +- tests/typesetting/test_cli.py | 6 +- 10 files changed, 967 insertions(+), 19 deletions(-) create mode 100644 docs/howto/compare.md create mode 100644 src/mathspec/canonical.py create mode 100644 tests/test_canonical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3236e56..d3400cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) +- feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#PR](https://github.com/energy-models/mathspec/pull/PR)) ## 0.2.0 (2026-09-25) diff --git a/docs/howto/compare.md b/docs/howto/compare.md new file mode 100644 index 00000000..a40b1005 --- /dev/null +++ b/docs/howto/compare.md @@ -0,0 +1,86 @@ + + +# Compare two specs + +Diff two spec files so that the diff shows only what the specs state +differently. A plain text diff also shows the order of declarations, spacing +and the order of the terms in a sum. The canonical form removes those +differences. [Comparing two specs](../reference/reading.md#comparing-two-specs) +lists what the form sorts and what it keeps. + +1. **Write one spec in the canonical form.** + + ```bash + python -m mathspec canonical spec.yaml + ``` + + The form goes to stdout. `-o canonical.yaml` writes it to a file instead. A + file the language refuses prints its message on stderr and exits with + status 1. + +2. **Diff two files once.** Give each file to the command, and diff the two + outputs: + + ```bash + diff <(python -m mathspec canonical before.yaml) <(python -m mathspec canonical after.yaml) + ``` + +3. **Make `git diff` show the canonical form.** Tell git which files are + specs, and which command writes them out: + + ```bash + echo 'specs/*.yaml diff=mathspec' >> .gitattributes + git config diff.mathspec.textconv "python -m mathspec canonical" + ``` + + The files in the repository stay as you wrote them. Only the diff changes. + A commit that writes `sum(dispatch * cost)` as `sum(cost * dispatch)` shows + no difference. A commit that changes a coefficient shows one line: + + ```diff + objective: + sense: minimize + - expression: sum(cost * dispatch) + + expression: sum((2 * cost) * dispatch) + ``` + + Match only spec files in `.gitattributes`. Git runs the command on every + file the pattern matches, and a YAML file that is not a spec fails to load. + +4. **Keep specs in the canonical form in CI.** Then the files themselves + diff the way the form does, with no git setup. `--write` rewrites a file in + the form. The form holds no YAML comments, so `--write` drops them: + + ```bash + python -m mathspec canonical --write spec.yaml + ``` + + `--check` writes nothing. It exits with status 1 if the file is not in the + form, and names the rewrite: + + ```text + spec.yaml is not in the canonical form. Run `python -m mathspec canonical --write spec.yaml` to rewrite it. + ``` + + Check every spec, and fail the job if one of them fails: + + ```bash + status=0 + for spec in specs/*.yaml; do python -m mathspec canonical --check "$spec" || status=1; done + exit $status + ``` + +5. **Compare from Python** where the comparison is one step of a longer + script. [`to_yaml`](../reference/api.md#mathspec.Spec.to_yaml) writes the + same text with `canonical=True`: + + ```python + import mathspec as ms + + before = ms.to_spec('before.yaml').to_yaml(canonical=True) + after = ms.to_spec('after.yaml').to_yaml(canonical=True) + print(before == after) + ``` diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 26c202a3..10672c07 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -230,3 +230,50 @@ that data as a file. Both round-trip, so `to_spec(spec.to_dict()) == spec`. `to_yaml()` writes every value and omits every absence. `domain: continuous` is written out. A `null` and an empty section are left out. `dims: []` is written, because it says the declaration is a scalar. + +## Comparing two specs + +`to_yaml(canonical=True)` writes the normal form: the one text every file that +states the same spec writes. Two specs then differ in a diff only where they +differ as specs. + +```python +spec.to_yaml(canonical=True) == to_spec(spec.to_yaml(canonical=True)).to_yaml(canonical=True) # True +``` + +- **The sections come in one order**, whatever order the file wrote them in: + `version`, `description`, `dimensions`, `relations`, `parameters`, + `variables`, `constraints`, `objective`, `expressions`, `macros`, + `piecewise`, `sos`, `assumptions`. The keys of a declaration also come in one + order. +- **Declarations are sorted by name** within each section. +- **Every expression is printed from its parsed tree**, so the spacing and the + brackets are the printer's rather than the author's. +- **The terms of a sum are sorted**, and so are the factors of a product and the + keyword arguments of a call. Subtraction, division, exponentiation and a + call's positional arguments keep the order the file wrote, because moving + those changes what the spec says. +- **A sum of two or more terms is broken one term to a line**, each under its + own sign. A term that changes is then one line of a diff. +- **A constant is never folded into another.** `2 * 3` stays `2 * 3`, because a + coefficient that changed is what a reviewer is looking for. + +Four things are left as the file wrote them. They are a predicate in the +`where` grammar, the order of a `cases:` block's regions, the order of a +declaration's `dims`, and the order of a piecewise block's links. A difference +in any of them is a difference in the text. + +Sorting `variables:` changes the order a +[`piecewise:`](language/piecewise.md) expansion meets them in, so a constraint +the expansion emits can carry its dims in another order. The frame is the same +set of dimensions. + +The normal form loads to the same spec. It does not load to a `Spec` equal to +the original: a reprinted expression is a different string. Writing the form out +again gives the same text, which is what the line above says. + +`python -m mathspec canonical spec.yaml` writes it from a shell. `--write` +rewrites the file in the form, and `--check` exits with status 1 if the file is +not in the form. The form holds no YAML comments, so `--write` drops them. +[Compare two specs](../howto/compare.md) shows how to diff two files in this +form. diff --git a/mkdocs.yml b/mkdocs.yml index 9ea73413..b5358127 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Fix a quantity that is data in one model and a decision in another: howto/pin-a-variable.md - Write a piecewise curve out by hand: howto/curve-by-hand.md - See what a curve or a set expands to: howto/see-an-expansion.md + - Compare two specs: howto/compare.md - Reference: - Language: - reference/language/index.md diff --git a/src/mathspec/__main__.py b/src/mathspec/__main__.py index c3cf1533..4523977a 100644 --- a/src/mathspec/__main__.py +++ b/src/mathspec/__main__.py @@ -4,9 +4,11 @@ """``python -m mathspec spec.yaml`` — the shell front. -``check`` loads the file and prints the language's advice; one further verb -per typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb -reads the file as written, and nothing here writes a formulation out unasked. +``check`` loads the file and prints the language's advice; ``canonical`` +writes the spec in the form two files that state the same spec share, or with +``--check`` asks whether the file is already in that form; one further verb per +typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb reads +the file as written, and nothing here writes a formulation out unasked. The typeset verbs take ``--expand``, because a shell cannot compose [`expand`][mathspec.spec.Spec.expand] the way a caller does and the rows are a different document; ``check`` has no such flag, because advice reads a block @@ -33,6 +35,13 @@ def parser() -> argparse.ArgumentParser: check = verbs.add_parser('check', help='load a spec, and print what the language advises') check.add_argument('spec', help='path to a mathspec YAML file') + canonical = verbs.add_parser('canonical', help='write a spec in the form two files that state it share') + canonical.add_argument('spec', help='path to a mathspec YAML file') + target = canonical.add_mutually_exclusive_group() + target.add_argument('-o', '--out', help='write here instead of stdout') + target.add_argument('--write', action='store_true', help='rewrite the file in the form, dropping its comments') + target.add_argument('--check', action='store_true', help='exit 1 if the file is not in the form, writing nothing') + for name in FORMATS: verb = verbs.add_parser(name, help=f'render a spec as {name}') verb.add_argument('spec', help='path to a mathspec YAML file') @@ -52,10 +61,26 @@ def parser() -> argparse.ArgumentParser: return front +def _checked(path: str, text: str) -> int: + """Exit status 0 if the file at *path* already holds *text*, the form, and 1 with the rewrite on stderr if not. + + A CI job runs this, so it compares bytes and writes nothing: the job fails, + and the author runs the rewrite the message names. + """ + if Path(path).read_text(encoding='utf-8') == text: + return 0 + sys.stderr.write( + f'{path} is not in the canonical form. Run `python -m mathspec canonical --write {path}` to rewrite it.\n' + ) + return 1 + + def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. + Advice is not a refusal: ``check`` prints it and exits 0. A file that + ``canonical --check`` finds out of the form exits 1, with the rewrite on + stderr. """ args = parser().parse_args(argv) if args.verb == 'check': @@ -66,16 +91,28 @@ def main(argv: list[str] | None = None) -> int: return 1 sys.stdout.write(''.join(f'{note}\n' for note in notes)) return 0 - spec = to_spec(args.spec).expand() if args.expand else args.spec - text = typeset( - spec, - args.verb, - symbols=args.symbols, - standalone=args.standalone, - legend=not args.no_legend, - numbered=not args.no_numbers, - inline_expressions=args.inline_expressions, - ) + if args.verb == 'canonical': + try: + text = to_spec(args.spec).to_yaml(canonical=True) + except MathSpecError as e: + sys.stderr.write(f'{e}\n') + return 1 + if args.check: + return _checked(args.spec, text) + if args.write: + Path(args.spec).write_text(text, encoding='utf-8') + return 0 + else: + spec = to_spec(args.spec).expand() if args.expand else args.spec + text = typeset( + spec, + args.verb, + symbols=args.symbols, + standalone=args.standalone, + legend=not args.no_legend, + numbered=not args.no_numbers, + inline_expressions=args.inline_expressions, + ) if args.out: Path(args.out).write_text(text, encoding='utf-8') else: diff --git a/src/mathspec/canonical.py b/src/mathspec/canonical.py new file mode 100644 index 00000000..6136504a --- /dev/null +++ b/src/mathspec/canonical.py @@ -0,0 +1,266 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""The normal form behind ``Spec.to_yaml(canonical=True)`` — one text for every file that means the same thing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, cast, overload + +import yaml + +from mathspec._expression_parser import ( + BinaryOperatorNode, + ComparisonNode, + FunctionCallNode, + UnaryOperatorNode, + operand, + parse_expression, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from mathspec._expression_parser import ArithmeticNode, ParsedNode + from mathspec.spec import Spec + +#: Which sign a term of a sum is written under, which is also the operator that +#: rebuilds it. +Sign = Literal['+', '-'] + +#: Where a declaration's expression text sits under a section, once the spec is +#: plain data. A link is the odd one: it serialises back to the ``[expression, +#: values]`` list the file wrote, so its expression is a position rather than a +#: key. +_EXPRESSION_KEYS = ('expression', 'otherwise', 'template') + + +def _signed(sign: Sign, node: ArithmeticNode) -> tuple[Sign, ArithmeticNode]: + """One term with a leading sign folded into the sign it is written under. + + ``+ -x`` is ``- x`` and ``- -x`` is ``+ x``, so a term sorts and prints + under one spelling however the file wrote it. Folding is also what makes + the walk the inverse of [`_sum`][mathspec.canonical._sum], which writes a leading minus term as + the negation it is — without it a second pass would read that minus as part + of the term and sort by it. + """ + while isinstance(node, UnaryOperatorNode): + if node.op == '-': + sign = '-' if sign == '+' else '+' + node = node.operand + return sign, node + + +def _signed_terms(node: ArithmeticNode, sign: Sign = '+') -> Iterator[tuple[Sign, ArithmeticNode]]: + """The sum *node* stands for under *sign*, as each term with the sign it carries. + + A sum under a plus is spliced into the sum around it, on either side. A + group left whole here would be printed into flat text and read back as one + term per line, which is a different tree from the one printed — the two + bugs in #530 were both that. + + A sum under a minus keeps its brackets and stays one term, so + ``a - (b - c)`` is not redistributed into ``a - b + c``. Splicing it would + flip every sign inside, which rewrites what the file wrote rather than + ordering it. + """ + sign, node = _signed(sign, node) + if sign == '+' and isinstance(node, BinaryOperatorNode) and node.op in ('+', '-'): + yield from _signed_terms(node.left) + yield from _signed_terms(node.right, node.op) + else: + yield sign, node + + +def _factors(node: ArithmeticNode) -> Iterator[ArithmeticNode]: + """The product *node* is, as its factors, on either side. + + A division stays one factor, its own node. A product is spliced for the + reason a sum is: ``(a * b) * c`` and ``a * (b * c)`` print the same flat + text, so a group left whole here would not survive being read back. + """ + if isinstance(node, BinaryOperatorNode) and node.op == '*': + yield from _factors(node.left) + yield from _factors(node.right) + else: + yield node + + +def _order(term: tuple[Sign, ArithmeticNode]) -> tuple[str, Sign]: + """The key a term of a sum sorts under: the text it prints as, then the sign it is written under. + + The sign is the tie-breaker rather than part of the text, so ``a - b`` and + ``b - a`` order their terms alike and differ only in which one is negative. + """ + sign, node = term + return str(node), sign + + +def _sum(terms: list[tuple[Sign, ArithmeticNode]]) -> ArithmeticNode: + """The terms back into one left-leaning spine, a leading minus becoming the sign it was.""" + sign, first = terms[0] + built: ArithmeticNode = first if sign == '+' else UnaryOperatorNode('-', first) + for sign, term in terms[1:]: + built = BinaryOperatorNode(sign, built, term) + return built + + +@overload +def normalised(node: ArithmeticNode) -> ArithmeticNode: ... +@overload +def normalised(node: ComparisonNode) -> ComparisonNode: ... + + +def normalised(node: ParsedNode) -> ParsedNode: + """*node* with every order the math does not fix put into one order. + + Sorting is by the text an operand prints as, which is a total order over + trees: a printed tree parses back to the tree it came from, so two + operands that print alike are one tree. Addition and multiplication + commute and are sorted. Subtraction, division, exponentiation and a call's + positional arguments are not, and keep the order the file wrote. + + Each term and factor is flattened *after* it is normalised as well as + before, because normalising reveals sums and products the written tree + hid — a unary plus over a product is one factor until the plus is folded + away. A group left whole here would print into flat text and read back as + several terms, which is a different tree from the one printed. + """ + if isinstance(node, ComparisonNode): + left, right = (normalised(side) for side in (node.left, node.right)) + return ComparisonNode(node.op, left, right) + if isinstance(node, FunctionCallNode): + return FunctionCallNode( + node.name, + tuple(normalised(arg) for arg in node.args), + {key: normalised(value) for key, value in sorted(node.kwargs.items())}, + ) + if isinstance(node, UnaryOperatorNode): + sign, inner = _signed('+', node) + under = normalised(inner) + return under if sign == '+' else UnaryOperatorNode('-', under) + if isinstance(node, BinaryOperatorNode): + if node.op in ('+', '-'): + terms: list[tuple[Sign, ArithmeticNode]] = [] + for sign, term in _signed_terms(node): + terms.extend(_signed_terms(normalised(term), sign)) + return _sum(sorted(terms, key=_order)) + if node.op == '*': + factors: list[ArithmeticNode] = [] + for factor in _factors(node): + factors.extend(_factors(normalised(factor))) + factors.sort(key=str) + product: ArithmeticNode = factors[0] + for factor in factors[1:]: + product = BinaryOperatorNode('*', product, factor) + return product + return BinaryOperatorNode(node.op, normalised(node.left), normalised(node.right)) + return node + + +def laid_out(node: ParsedNode) -> str: + """*node* as text, one term of its leading sum per line. + + A term that changes is then one line of a diff rather than a rewritten + expression. Every line after the first opens with its own sign, and a + comparison's operator opens the last, which is text the grammar reads back + as the same tree. An expression of one term stays on one line. + + Each term is bracketed the way any operand is, and the first through + [`_sum`][mathspec.canonical._sum], because a line is read back as part of the whole: ``- b - c`` + under a leading ``a`` is ``a - b - c``, where the term written was + ``(b - c)``. An expression of one term is one line, operator and all. + """ + head = node.left if isinstance(node, ComparisonNode) else node + terms = list(_signed_terms(head)) + if len(terms) < 2: + return str(node) + lines = [str(_sum(terms[:1]))] + lines += [f'{sign} {operand(term)}' for sign, term in terms[1:]] + if isinstance(node, ComparisonNode): + lines.append(f'{node.op} {node.right}') + return '\n'.join(lines) + + +def canonical_text(text: str) -> str: + """One expression string in the normal form, parsed and printed rather than edited.""" + return laid_out(normalised(parse_expression(text))) + + +def _canonical_block(block: object) -> object: + """One declaration, with every expression under it normalised and everything else untouched.""" + if isinstance(block, dict): + return { + key: canonical_text(value) + if key in _EXPRESSION_KEYS and isinstance(value, str) + else _canonical_block(value) + for key, value in block.items() + } + if isinstance(block, list): + return [_canonical_block(item) for item in block] + return block + + +def _canonical_links(links: list[list[object]]) -> list[list[object]]: + """A piecewise block's links, whose expression is the first position of the list the file wrote.""" + return [[canonical_text(cast('str', link[0])), *link[1:]] for link in links] + + +def _sorted_blocks(section: dict[str, object], *, bare_is_expression: bool = False) -> dict[str, object]: + """One section's declarations sorted by name, each with its expressions in the normal form. + + A named expression written on one line serialises back as a bare string, + which is the expression itself, so *bare_is_expression* normalises it too. + """ + return { + name: canonical_text(block) if bare_is_expression and isinstance(block, str) else _canonical_block(block) + for name, block in sorted(section.items()) + } + + +def canonical_dict(spec: Spec) -> dict[str, object]: + """The spec as plain data, in the form two files that state the same spec share. + + Declarations are sorted by name and every expression is printed from its + parsed tree, so what is left of a difference is a difference in the spec. + A ``where`` string, the order of a ``cases:`` block's regions, the order of + a declaration's ``dims`` and the order of a piecewise block's links are all + left as written. + + Args: + spec: The loaded spec. + + Returns: + Plain data, ready for [`canonical_yaml`][mathspec.canonical.canonical_yaml]. + Loading it gives the same spec back, and not a `Spec` equal to *spec*: + an expression reprinted in the normal form is a different string. + """ + data = spec.to_dict() + built: dict[str, object] = {} + for section, value in data.items(): + if isinstance(value, dict) and section != 'objective': + built[section] = _sorted_blocks(value, bare_is_expression=section == 'expressions') + else: + built[section] = _canonical_block(value) + piecewise = built.get('piecewise') + if isinstance(piecewise, dict): + for block in piecewise.values(): + block['links'] = _canonical_links(block['links']) + return built + + +class _Dumper(yaml.SafeDumper): + """A dumper writing a multi-line expression as a block scalar, rather than escaping the newlines into one line.""" + + +def _scalar(dumper: yaml.SafeDumper, text: str) -> yaml.ScalarNode: + return dumper.represent_scalar('tag:yaml.org,2002:str', text, style='|' if '\n' in text else None) + + +_Dumper.add_representer(str, _scalar) + + +def canonical_yaml(spec: Spec) -> str: + """The file a reviewer diffs — [`canonical_dict`][mathspec.canonical.canonical_dict] as YAML, each expression term on its own line.""" + return yaml.dump(canonical_dict(spec), Dumper=_Dumper, sort_keys=False, allow_unicode=True) diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 5e3d09df..37b9a1b4 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -815,10 +815,24 @@ def to_dict(self) -> dict[str, object]: """The spec as plain data. ``to_spec(m.to_dict())`` reproduces it.""" return self.model_dump() - def to_yaml(self) -> str: - """The file a reviewer reads — including for a spec that never had one.""" + def to_yaml(self, *, canonical: bool = False) -> str: + """The file a reviewer reads — including for a spec that never had one. + + Args: + canonical: Write the normal form instead: declarations sorted by + name, every expression printed from its parsed tree, one term + of a sum per line. Two files that state the same spec write + the same text, so what a diff shows is a difference in the + spec. The normal form loads to the same spec and not to an + equal [`Spec`][mathspec.spec.Spec], a reprinted expression + being a different string. + """ import yaml + if canonical: + from mathspec.canonical import canonical_yaml + + return canonical_yaml(self) return yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True) def expand(self, *kinds: Formulation) -> Spec: diff --git a/tests/test_canonical.py b/tests/test_canonical.py new file mode 100644 index 00000000..fe8476b4 --- /dev/null +++ b/tests/test_canonical.py @@ -0,0 +1,494 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""The normal form: one text for every file that means the same thing. + +A reviewer diffs two specs in an editor, and a text diff reports key order, +spacing and the order of terms in a sum — none of which is a difference in the +spec. What is left after this form is applied is what the two files mean +differently, which is the question `to_yaml(canonical=True)` exists to answer. +""" + +from __future__ import annotations + +import itertools +import random +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import yaml + +import mathspec as ms +from mathspec.__main__ import main +from mathspec._expression_parser import ( + BinaryOperatorNode, + ComparisonNode, + FunctionCallNode, + NameNode, + NumberNode, + UnaryOperatorNode, + operand, + parse_expression, +) +from mathspec.canonical import _factors, _signed_terms, canonical_text, laid_out, normalised +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, raw_of + +if TYPE_CHECKING: + from mathspec._expression_parser import ArithmeticNode, ParsedNode + +#: Every spec in the repository, the operator probes included. The symbol +#: tables under `examples/symbols/` are not specs and do not load as one. +SPECS = [path for path in sorted(EXAMPLES.rglob('*.yaml')) if 'symbols' not in path.parts] +FIXTURE = Path(__file__).resolve().parent / 'fixtures' / 'every_program_node.yaml' + + +def _dumped(**patch: object) -> str: + return ms.to_spec(override(DISPATCH_MODEL, **patch)).to_yaml(canonical=True) + + +def test_two_files_that_mean_the_same_thing_write_the_same_text(): + """The claim the form makes, over the three ambiguities a file carries: the + order two declarations are written in, the order of the terms of a sum, and + the order of the factors of a product.""" + one = ms.to_spec( + { + 'dimensions': {'h': {'dtype': 'int'}, 'u': {'dtype': 'str'}}, + 'parameters': {'cost': {'dims': ['u']}, 'cap': {'dims': ['u']}}, + 'variables': {'p': {'dims': ['h', 'u'], 'bounds': {'lower': 0}}}, + 'constraints': {'k': {'dims': ['h', 'u'], 'expression': 'p * cost + p - cap <= 10'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(p * cost)'}, + } + ) + other = ms.to_spec( + { + 'parameters': {'cap': {'dims': ['u']}, 'cost': {'dims': ['u']}}, + 'dimensions': {'u': {'dtype': 'str'}, 'h': {'dtype': 'int'}}, + 'variables': {'p': {'dims': ['h', 'u'], 'bounds': {'lower': 0}}}, + 'constraints': {'k': {'dims': ['h', 'u'], 'expression': '-cap + p + cost * p <= 10'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(cost * p)'}, + } + ) + assert one.to_yaml(canonical=True) == other.to_yaml(canonical=True), 'the two files state one spec' + assert one.to_yaml() != other.to_yaml(), 'and the dump that keeps the file as written still tells them apart' + + +@pytest.mark.parametrize('path', [*SPECS, FIXTURE], ids=lambda path: path.stem) +def test_the_form_loads_and_is_already_in_the_form(path): + """Every spec in the tree, dumped, re-read and dumped again. + + Idempotence is the property that makes the text a *normal* form rather than + a rewriting: a second pass that moved anything would mean two files in the + form could still differ. It is also what catches a print the grammar reads + back as a different tree, which a leading negative term did. + """ + text = ms.to_spec(path).to_yaml(canonical=True) + assert ms.to_spec(text).to_yaml(canonical=True) == text, 'the form is a fixed point of itself' + + +@pytest.mark.parametrize( + ('written', 'canonical'), + [ + pytest.param('a + c + b', 'a\n+ b\n+ c', id='the-terms-of-a-sum-are-sorted'), + pytest.param('c * a * b', '(a * b) * c', id='and-the-factors-of-a-product'), + pytest.param('b - a', '-a\n+ b', id='a-term-carries-its-sign-as-it-moves'), + pytest.param('a + -b', 'a\n- b', id='and-a-sign-written-twice-is-folded-once'), + pytest.param('a - (b - c)', 'a\n- (b - c)', id='a-bracketed-group-stays-one-term'), + pytest.param('b - a - c', '-a\n+ b\n- c', id='terms-sort-under-the-signs-they-keep'), + pytest.param('2 * 3 * a', '(2 * 3) * a', id='a-constant-is-never-folded-into-another'), + pytest.param('b / a', 'b / a', id='a-division-is-not-reordered'), + pytest.param('b ** a', 'b ** a', id='nor-is-an-exponent'), + pytest.param('shift(p, edge=0, along=t)', 'shift(p, along=t, edge=0)', id='a-calls-kwargs-are-sorted'), + pytest.param('at(q, by=r) - p', 'at(q, by=r)\n- p', id='a-call-sorts-as-the-text-it-prints'), + ], +) +def test_an_expression_is_written_one_way(written, canonical): + assert canonical_text(written) == canonical, 'the normal form of the expression, whole' + + +def test_a_positional_argument_keeps_the_place_the_file_gave_it(): + """`sum(a, b)` is not `sum(b, a)` for any operator that takes two operands, + so position is meaning and only keywords are sorted.""" + assert canonical_text('max(b, a)') == 'max(b, a)', 'the arguments stand where they were written' + + +def test_an_expression_of_one_term_stays_on_one_line(): + dumped = _dumped(**{'constraints.balance.expression': 'sum(p, over=generator) == load'}) + assert 'expression: sum(p, over=generator) == load' in dumped, 'nothing to break across lines' + + +def test_a_sum_is_broken_one_term_to_a_line(): + """What a term-per-line layout buys: a changed term is one line of a diff + rather than a rewritten expression.""" + dumped = _dumped(**{'constraints.balance.expression': 'sum(p, over=generator) - load + 1 == 0'}) + assert '\n 1\n - load\n + sum(p, over=generator)\n == 0\n' in dumped, ( + 'the block scalar holds one term per line, each under its own sign, and the comparison closes it' + ) + + +def test_the_declarations_of_a_section_are_sorted_by_name(): + dumped = ms.to_spec( + override(DISPATCH_MODEL, **{'constraints.a_cap.dims': [], 'constraints.a_cap.expression': 'sum(p) >= 0'}) + ).to_yaml(canonical=True) + assert dumped.index('a_cap:') < dumped.index('balance:'), 'a section reads in name order, not file order' + + +#: The order the form writes the sections in, whatever order the file wrote +#: them in. It is the order of the fields on `Spec`, pinned here so that a +#: reordered field is a failing test rather than a quiet change to every diff. +SECTIONS = [ + 'version', + 'description', + 'dimensions', + 'relations', + 'parameters', + 'variables', + 'constraints', + 'objective', + 'expressions', + 'macros', + 'piecewise', + 'sos', + 'assumptions', +] + + +def _written_backwards(raw: object, depth: int = 3) -> object: + """*raw* with every mapping down to *depth* levels written in reverse order. + + Three levels reach the sections, the declarations of each section and the + keys of each declaration. They stop above a `cases:` block, whose regions + are a mapping that the form keeps in the file's order. + """ + if depth == 0 or not isinstance(raw, dict): + return raw + return {key: _written_backwards(value, depth - 1) for key, value in reversed(raw.items())} + + +@pytest.mark.parametrize('path', SPECS, ids=lambda path: path.stem) +def test_a_file_written_backwards_writes_the_same_text(path): + """The section order, the declaration order in each section and the key + order in each declaration are spelling. A file that writes all three in + reverse is the same spec, so it writes the same text.""" + raw = raw_of(path) + backwards = ms.to_spec(_written_backwards(raw)).to_yaml(canonical=True) + assert backwards == ms.to_spec(raw).to_yaml(canonical=True), 'three orders reversed, one text' + + +@pytest.mark.parametrize('path', SPECS, ids=lambda path: path.stem) +def test_the_sections_come_in_one_order(path): + written = list(yaml.safe_load(ms.to_spec(path).to_yaml(canonical=True))) + assert written == [section for section in SECTIONS if section in written], ( + f'the sections of {path.name} follow SECTIONS, not the file' + ) + + +@pytest.mark.parametrize('path', SPECS, ids=lambda path: path.stem) +def test_every_section_is_sorted_by_name(path): + data = yaml.safe_load(ms.to_spec(path).to_yaml(canonical=True)) + for section, declarations in data.items(): + if isinstance(declarations, dict) and section != 'objective': + assert list(declarations) == sorted(declarations), f'{path.name}: `{section}` reads in name order' + + +def _commitment_with_its_cases_reversed() -> dict[str, object]: + raw = raw_of(EXAMPLES / 'commitment.yaml') + cases = raw['expressions']['previous_status']['cases'] + return override(raw, **{'expressions.previous_status.cases': dict(reversed(cases.items()))}) + + +def _piecewise_with_its_links_reversed() -> dict[str, object]: + raw = raw_of(EXAMPLES / 'piecewise.yaml') + return override(raw, **{'piecewise.cost_curve.links': raw['piecewise']['cost_curve']['links'][::-1]}) + + +@pytest.mark.parametrize( + ('written', 'reordered'), + [ + pytest.param( + DISPATCH_MODEL, + override(DISPATCH_MODEL, **{'variables.p.dims': ['generator', 'snapshot']}), + id='a-declarations-dims', + ), + pytest.param( + raw_of(EXAMPLES / 'commitment.yaml'), + _commitment_with_its_cases_reversed(), + id='the-regions-of-a-cases-block', + ), + pytest.param( + raw_of(EXAMPLES / 'piecewise.yaml'), + _piecewise_with_its_links_reversed(), + id='the-links-of-a-piecewise-block', + ), + pytest.param( + override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 and cost > 0'}), + override(DISPATCH_MODEL, **{'variables.p.where': 'cost > 0 and p_max > 0'}), + id='the-predicates-of-a-where', + ), + ], +) +def test_an_order_the_form_keeps_is_a_difference_in_the_text(written, reordered): + """The orders the form does not sort, each reversed alone. A change here + changes what a reviewer sees as a difference, so it is a decision.""" + assert ms.to_spec(written).to_yaml(canonical=True) != ms.to_spec(reordered).to_yaml(canonical=True), ( + 'the form keeps this order as the file wrote it' + ) + + +def test_the_dump_that_keeps_the_file_as_written_is_unchanged(): + """The default is the round trip `reading.md` documents, and the form is an + argument away rather than a change to it.""" + spec = ms.to_spec(DISPATCH_MODEL) + assert ms.to_spec(spec.to_yaml()) == spec, 'the file as written still reproduces the spec it came from' + + +def test_the_shell_writes_the_form_a_reviewer_diffs(tmp_path, capsys): + """The verb is the whole point of the form: a reviewer works in a shell and + a version control diff, not in Python.""" + model = tmp_path / 'spec.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml()) + assert main(['canonical', str(model)]) == 0, 'a spec the language accepts exits 0' + assert capsys.readouterr().out == ms.to_spec(model).to_yaml(canonical=True), 'stdout is the form, whole' + + +def test_the_shell_refuses_a_spec_the_language_refuses(tmp_path, capsys): + model = tmp_path / 'broken.yaml' + model.write_text('constraints: {k: {dims: [], expression: "p >= "}}\n') + assert main(['canonical', str(model)]) == 1, 'a refused file is exit status 1' + assert capsys.readouterr().err, 'and its message on stderr' + + +def test_the_check_passes_a_file_in_the_form_and_writes_nothing(tmp_path, capsys): + model = tmp_path / 'spec.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml(canonical=True)) + assert main(['canonical', '--check', str(model)]) == 0, 'a file in the form passes' + assert capsys.readouterr() == ('', ''), 'a check that passes prints nothing on either stream' + + +def test_the_check_fails_a_file_out_of_the_form_and_names_the_rewrite(tmp_path, capsys): + """What a CI job runs: the spec loads, so the language accepts it, and the + job still fails, because the file is not the text the form writes.""" + model = tmp_path / 'spec.yaml' + written = ms.to_spec(DISPATCH_MODEL).to_yaml() + model.write_text(written) + assert main(['canonical', '--check', str(model)]) == 1, 'a file out of the form is exit status 1' + assert capsys.readouterr().err == ( + f'{model} is not in the canonical form. Run `python -m mathspec canonical --write {model}` to rewrite it.\n' + ) + assert model.read_text() == written, 'the check leaves the file as it found it' + + +def test_the_rewrite_leaves_a_file_the_check_passes(tmp_path, capsys): + model = tmp_path / 'spec.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml()) + assert main(['canonical', '--write', str(model)]) == 0, 'the rewrite of a spec the language accepts exits 0' + assert model.read_text() == ms.to_spec(DISPATCH_MODEL).to_yaml(canonical=True), 'the file now holds the form' + assert capsys.readouterr().out == '', 'the form goes into the file, not to stdout' + assert main(['canonical', '--check', str(model)]) == 0, 'and the check passes the file it wrote' + + +@pytest.mark.parametrize('flag', ['--check', '--write']) +def test_a_refused_file_is_neither_checked_nor_rewritten(tmp_path, capsys, flag): + model = tmp_path / 'broken.yaml' + written = 'constraints: {k: {dims: [], expression: "p >= "}}\n' + model.write_text(written) + assert main(['canonical', flag, str(model)]) == 1, 'a refused file is exit status 1' + assert capsys.readouterr().err, 'and its message on stderr' + assert model.read_text() == written, 'the file is left as the author wrote it' + + +@pytest.mark.parametrize( + 'flags', + [ + pytest.param(['--check', '--write'], id='check-and-write'), + pytest.param(['--check', '-o', 'out.yaml'], id='check-and-out'), + pytest.param(['--write', '-o', 'out.yaml'], id='write-and-out'), + ], +) +def test_the_form_goes_to_one_place(tmp_path, flags): + """Each flag names where the form goes, so two of them name two places.""" + model = tmp_path / 'spec.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml()) + with pytest.raises(SystemExit) as refused: + main(['canonical', *flags, str(model)]) + assert refused.value.code == 2, 'argparse refuses the pair before the file is read' + + +# --------------------------------------------------------------------------- +# What the form promises, over trees nobody wrote by hand +# --------------------------------------------------------------------------- + +#: The leaves a generated tree draws from. Two names that share a prefix and one +#: that sorts last make an ordering mistake visible. +LEAVES = ['a', 'b', 'c', 'd', 'long_name'] + + +def _tree(rng: random.Random, depth: int = 0) -> ArithmeticNode: + """One random expression tree, every node kind an expression can carry.""" + if depth >= 4 or rng.random() < 0.28: + return rng.choice([NameNode(rng.choice(LEAVES)), NumberNode(float(rng.randint(1, 5)))]) + roll = rng.random() + if roll < 0.12: + return UnaryOperatorNode(rng.choice(['-', '+']), _tree(rng, depth + 1)) + if roll < 0.24: + args = tuple(_tree(rng, depth + 2) for _ in range(rng.randint(1, 2))) + keywords = rng.sample(['over', 'by', 'along', 'within'], rng.randint(0, 3)) + return FunctionCallNode( + rng.choice(['sum', 'at', 'shift']), args, {key: NameNode(rng.choice(LEAVES)) for key in keywords} + ) + return BinaryOperatorNode(rng.choice(['+', '-', '*', '/', '**']), _tree(rng, depth + 1), _tree(rng, depth + 1)) + + +def _top(rng: random.Random) -> ParsedNode: + node = _tree(rng) + return ComparisonNode(rng.choice(['<=', '>=', '==']), node, _tree(rng, 2)) if rng.random() < 0.3 else node + + +def _value(node: ArithmeticNode, env: dict[str, float]) -> float: + """What the tree computes, for the one question a normal form must answer the same. + + A call is read as the sum of its arguments and its keywords are ignored, + because what is under test is the arithmetic around it: the form normalises + each argument in place and sorts the keywords, and both leave this reading + alone. + """ + if isinstance(node, NumberNode): + return node.value + if isinstance(node, NameNode): + return env[node.name] + if isinstance(node, UnaryOperatorNode): + return -_value(node.operand, env) if node.op == '-' else _value(node.operand, env) + if isinstance(node, FunctionCallNode): + return sum(_value(argument, env) for argument in node.args) + assert isinstance(node, BinaryOperatorNode), f'{node} is not a kind this evaluator was given' + left, right = _value(node.left, env), _value(node.right, env) + return {'+': left + right, '-': left - right, '*': left * right, '/': left / right, '**': left**right}[node.op] + + +@pytest.mark.parametrize( + ('written', 'terms'), + [ + pytest.param('a + (b + c)', ['+ a', '+ b', '+ c'], id='a-sum-under-a-plus-is-spliced'), + pytest.param('a + (b - c)', ['+ a', '+ b', '- c'], id='signs-and-all'), + pytest.param('(a + b) + c', ['+ a', '+ b', '+ c'], id='on-the-left-as-well-as-the-right'), + pytest.param('a - (b - c)', ['+ a', '- (b - c)'], id='and-one-under-a-minus-is-left-whole'), + pytest.param('- -(a + b)', ['+ a', '+ b'], id='a-sign-written-twice-cancels-and-splices'), + ], +) +def test_a_sum_flattens_into_the_terms_its_text_shows(written, terms): + """What the flatten owes the layout: the terms it yields are the lines that + get printed, so a group it leaves whole must be one the text brackets. A + group under a plus is not, which is why it is spliced.""" + found = [f'{sign} {operand(node)}' for sign, node in _signed_terms(parse_expression(written))] + assert found == terms, 'every term of the sum, in the order the spine holds them, and no group left flat' + + +@pytest.mark.parametrize( + ('written', 'factors'), + [ + pytest.param('a * (b * c)', ['a', 'b', 'c'], id='a-product-on-the-right-is-spliced'), + pytest.param('(a * b) * c', ['a', 'b', 'c'], id='and-on-the-left'), + pytest.param('a * (b / c)', ['a', 'b / c'], id='a-division-stays-one-factor'), + ], +) +def test_a_product_flattens_from_either_side(written, factors): + """Both sides, for the sum's reason: `(a * b) * c` and `a * (b * c)` print + the same flat text, so a group left whole here would not survive it.""" + assert [str(node) for node in _factors(parse_expression(written))] == factors, ( + 'every factor of the product, and no group left flat' + ) + + +def test_the_form_is_a_fixed_point_on_any_tree_at_all(): + """2000 generated trees, where the specs in the tree carry a few hundred + shapes between them. Three bugs were found here and nowhere else, all of one + kind: text that the grammar reads back as a *different* tree, because a + group the form kept as one term prints into flat text that is several.""" + for seed in range(2000): + node = _top(random.Random(seed)) + printed = laid_out(normalised(node)) + assert normalised(parse_expression(printed)) == normalised(node), ( + f'seed {seed}: `{node}` printed as `{printed}`, which reads back as another tree' + ) + + +def test_the_form_computes_what_the_expression_it_came_from_computes(): + """The property under every other one. Sorting terms, folding signs and + splicing groups are all rewrites, and a rewrite that changed a value would + be a wrong answer rather than an untidy file.""" + for seed in range(500): + node = _tree(random.Random(seed)) + rewritten = parse_expression(laid_out(normalised(node))) + env = {name: 1.5 + index for index, name in enumerate(LEAVES)} + try: + before, after = _value(node, env), _value(rewritten, env) + except (ZeroDivisionError, OverflowError): + continue + assert abs(before - after) <= 1e-9 * max(1.0, abs(before)), ( + f'seed {seed}: `{node}` is {before} and `{rewritten}` is {after}' + ) + + +def test_every_spelling_of_one_sum_writes_one_text(): + """Written in any order, with any term negated, one sum writes one text — + which is the whole claim the form makes, over every permutation rather than + the one a test author would have picked.""" + for seed in range(150): + rng = random.Random(seed) + terms = [str(_tree(rng, 2)) for _ in range(rng.randint(3, 4))] + signs = [rng.choice(['+', '-']) for _ in terms] + written = set() + for order in itertools.permutations(range(len(terms))): + head, *rest = order + text = f'-({terms[head]})' if signs[head] == '-' else f'({terms[head]})' + text += ''.join(f' {signs[i]} ({terms[i]})' for i in rest) + written.add(canonical_text(text)) + assert len(written) == 1, f'seed {seed}: one sum wrote {len(written)} texts: {sorted(written)}' + + +@pytest.mark.parametrize('path', SPECS, ids=lambda path: path.stem) +def test_the_form_declares_the_same_spec(path): + """The form is not only stable, it is the same spec: every declaration is + there, under its own name, on its own frame. What the form is allowed to + change is the text of an expression and the order two declarations sit in. + + A frame is compared as the set it is. Sorting `variables:` changes the + order a `piecewise:` expansion meets them in, so a constraint the expansion + emits can carry the same dims in another order — the same frame, laid out + differently by a consumer that reads that order. + """ + original, rewritten = ( + ms.to_spec(source).expand('piecewise').program for source in (path, ms.to_spec(path).to_yaml(canonical=True)) + ) + groups = ( + 'parameters', + 'variables', + 'constraints', + 'expressions', + 'piecewise', + 'sos', + 'dimensions', + 'relations', + 'assumptions', + ) + for group in groups: + assert sorted(getattr(original, group)) == sorted(getattr(rewritten, group)), ( + f'{group}: the form declares a different set of names' + ) + assert [(name, frozenset(block.dims), block.sense) for name, block in sorted(rewritten.constraints.items())] == [ + (name, frozenset(block.dims), block.sense) for name, block in sorted(original.constraints.items()) + ], 'every constraint keeps its frame and its sense' + assert [(name, frozenset(block.dims), block.domain) for name, block in sorted(rewritten.variables.items())] == [ + (name, frozenset(block.dims), block.domain) for name, block in sorted(original.variables.items()) + ], 'and every variable its frame and its domain' + assert (rewritten.objective is None) == (original.objective is None), 'an objective is kept, or its absence is' + if original.objective is not None and rewritten.objective is not None: + assert rewritten.objective.sense == original.objective.sense, 'and its sense with it' + + +def test_a_named_expression_written_on_one_line_is_normalised_too(): + """`name: a + b` serialises back as a bare string, which the form passed through as written.""" + frame = {'dimensions': {'t': {'dtype': 'int'}}, 'variables': {'a': {'dims': ['t']}, 'b': {'dims': ['t']}}} + one, other = ({**frame, 'expressions': {'total': text}} for text in ('a + b', 'b + a')) + assert ms.to_spec(one).to_yaml(canonical=True) == ms.to_spec(other).to_yaml(canonical=True) diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index c3180fc5..e1e910fd 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 19, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 20, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index 595809bb..f3c4a0a6 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -39,14 +39,16 @@ def _verbs() -> dict[str, argparse.ArgumentParser]: raise AssertionError('the front registered no subcommands at all') -def test_the_verbs_are_check_and_the_formats_and_nothing_else(): +def test_the_verbs_are_the_two_readers_and_the_formats_and_nothing_else(): """The one claim the module makes about itself, in both directions. The typeset verbs are built by looping over `FORMATS`, so a new format arrives with its verb already written and there is no second list to forget. A verb hand-added here, or a format quietly dropped, breaks this. + `check` reads a model and reports, `canonical` reads one and writes it back + in the form two files that mean the same thing share; neither renders. """ - assert set(_verbs()) == set(FORMATS) | {'check'} + assert set(_verbs()) == set(FORMATS) | {'check', 'canonical'} UNUSED_DIMENSION = """ From 1326aba13487882e92480f379ca0db7f3646156c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:22:46 +0000 Subject: [PATCH 50/75] Add the changelog link for #731 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3400cc8..d4f02636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) -- feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#PR](https://github.com/energy-models/mathspec/pull/PR)) +- feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) From cc7632fae2168407c5cae75b181c20b52a8ca6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:32:11 +0000 Subject: [PATCH 51/75] feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it merge composes fragments as peers: each loads on its own, owns what it declares, and reads what a sibling declares under given:, which now takes parameters, variables, named expressions and row families. A given expression's dims bound what it reads. A given: expressions: entry marked additive: true says the name is a sum other files add terms to; each contributor declares an ordinary named expression, merge sums them and keeps the marked entry. override lays patches over a base, field by field, with null as the removal. Both return a loaded Spec. Squashes #571, #690, #728 and #729 onto #731, in #721's words. Adds the tutorial 'A spec in several files', with a test that runs every step, and sorts the names under given: in the canonical form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- .prettierignore | 4 + CHANGELOG.md | 1 + docs/about/limits.md | 12 +- docs/examples/index.md | 3 + docs/examples/library/composed.md | 269 ++++++++ docs/examples/library/generator.md | 114 ++++ docs/examples/library/index.md | 65 ++ docs/examples/library/load.md | 69 ++ docs/examples/library/surface.md | 77 +++ docs/howto/compose.md | 296 ++++++++ docs/reference/api.md | 16 + docs/reference/glossary.md | 6 + docs/reference/language/declarations.md | 220 +++++- docs/reference/language/errors.md | 1 + docs/reference/language/file.md | 5 +- docs/reference/language/index.md | 2 +- docs/reference/reading.md | 38 ++ docs/several-files.md | 301 ++++++++ examples/library/generator.yaml | 37 + examples/library/load.yaml | 25 + examples/library/surface.yaml | 24 + examples/library/variants/commitment.yaml | 21 + examples/symbols/library.yaml | 24 + mkdocs.yml | 8 + pyproject.toml | 5 +- schema/mathspec.schema.json | 197 +++++- src/mathspec/__init__.py | 3 + src/mathspec/advising.py | 58 +- src/mathspec/boundedness.py | 2 +- src/mathspec/canonical.py | 8 +- src/mathspec/composition.py | 791 ++++++++++++++++++++++ src/mathspec/dimensions.py | 20 +- src/mathspec/errors.py | 2 +- src/mathspec/lowering.py | 25 +- src/mathspec/program.py | 52 ++ src/mathspec/resolution.py | 22 +- src/mathspec/spec.py | 107 ++- src/mathspec/typesetting/__init__.py | 21 +- src/mathspec/typesetting/legend.py | 48 +- src/mathspec/typesetting/symbols.py | 47 +- src/mathspec/typesetting/walk.py | 14 +- src/mathspec/validation.py | 45 +- tests/fixtures.py | 10 +- tests/test_additive.py | 262 +++++++ tests/test_advice.py | 42 +- tests/test_boundedness.py | 4 +- tests/test_canonical.py | 35 +- tests/test_composition.py | 532 +++++++++++++++ tests/test_dimensions.py | 6 +- tests/test_expand.py | 4 +- tests/test_given.py | 578 ++++++++++++++++ tests/test_library_example.py | 108 +++ tests/test_lowering.py | 44 +- tests/test_piecewise.py | 22 +- tests/test_public_surface.py | 2 + tests/test_reading_page.py | 2 +- tests/test_several_files_page.py | 96 +++ tests/test_sos.py | 14 +- tests/test_validation.py | 32 +- tests/typesetting/test_cases.py | 14 +- tests/typesetting/test_declaration.py | 37 +- tests/typesetting/test_formats.py | 14 +- tests/typesetting/test_symbols.py | 10 +- tests/typesetting/test_walk.py | 62 +- tools/_page.py | 6 + tools/gallery.py | 78 ++- tools/home_math.py | 9 +- tools/render_tex.py | 6 +- 68 files changed, 4903 insertions(+), 231 deletions(-) create mode 100644 docs/examples/library/composed.md create mode 100644 docs/examples/library/generator.md create mode 100644 docs/examples/library/index.md create mode 100644 docs/examples/library/load.md create mode 100644 docs/examples/library/surface.md create mode 100644 docs/howto/compose.md create mode 100644 docs/several-files.md create mode 100644 examples/library/generator.yaml create mode 100644 examples/library/load.yaml create mode 100644 examples/library/surface.yaml create mode 100644 examples/library/variants/commitment.yaml create mode 100644 examples/symbols/library.yaml create mode 100644 src/mathspec/composition.py create mode 100644 tests/test_additive.py create mode 100644 tests/test_composition.py create mode 100644 tests/test_given.py create mode 100644 tests/test_library_example.py create mode 100644 tests/test_several_files_page.py diff --git a/.prettierignore b/.prettierignore index 139038b2..84a89032 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,6 +24,10 @@ docs/examples/pypsa_linearized_uc.md docs/examples/pypsa_losses.md docs/examples/pypsa_stochastic.md docs/examples/pypsa_multi_period.md +docs/examples/library/surface.md +docs/examples/library/generator.md +docs/examples/library/load.md +docs/examples/library/composed.md # The PyPSA reference scripts write this file; prettier would reformat what # they stamp, and the two would fight over it exactly as above. diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f02636..d93fc858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) +- feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#PR](https://github.com/energy-models/mathspec/pull/PR)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) diff --git a/docs/about/limits.md b/docs/about/limits.md index 1fc232a1..70622ed8 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -12,7 +12,7 @@ or keyword. For the rules a spec itself has to obey, read ## How a new construct enters -A request for something new is one of three kinds, and the kind decides what it +A request for something new is one of four kinds, and the kind decides what it costs to add. - **A macro** is a template with arguments, written in the file under `macros:`. @@ -28,8 +28,14 @@ costs to add. constraints and assumptions, and no parameter, so [`spec.expand()`](../reference/language/piecewise.md#writing-a-formulation-out) writes it out with the data the spec already expects. - -A request that is none of the three is refused, and the +- **A declaration section** is a block of declarations of one kind, such as + `variables:` or `given:`. One enters where it states something no section + states, where a file decides it without data, and where the typesetter prints + it. `given:` entered on all three. No other section says that a parameter, + a column, a named expression or a row family belongs to another file, and + that is what lets a component file load and print on its own. + +A request that is none of the four is refused, and the [table of refusals](#deliberate-non-primitives) records it with what to write instead. diff --git a/docs/examples/index.md b/docs/examples/index.md index 1822edd1..160d513e 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -14,6 +14,9 @@ Every spec is a file under `examples/` in the repository. by region, so a single inequality covers both regimes. - [One construct per spec](operators.md) declares each operator in the smallest file that can, and prints the equation beside it. +- [A component library](library/index.md) is several files that compose into + one spec. Each file reads the coupling surface and prints on its own, and the + composed page shows what `merge` returns. The PyPSA parity pages, from [PyPSA in one file](pypsa.md) on, are a proof of concept. They sit in the Development section. diff --git a/docs/examples/library/composed.md b/docs/examples/library/composed.md new file mode 100644 index 00000000..2a4c0e0d --- /dev/null +++ b/docs/examples/library/composed.md @@ -0,0 +1,269 @@ + + +# The composed spec + +What [the surface](surface.md), [generators](generator.md) and +[loads](load.md) make together: + +```python +import mathspec as ms + +spec = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'load': 'load.yaml'}) +``` + +The file below is `spec`, the spec `merge` returns, written as YAML with +every default spelled out. No +fragment holds it, and nothing in the repository commits it. `Port_p` is one +declaration here. Each component fragment read it under `given:`, and merging +folded those readings into the surface's own declaration. + +The objective is the generator's, carried as it was written, since no other +fragment prices anything. A second priced fragment would add its term to this +one, each term in parentheses. + +The math under the file has a tab per formulation. **As composed** is the spec +above. **With commitment** lays `variants/commitment.yaml` over it with +[`override`](../../howto/compose.md#a-base-and-its-patches), which makes the +generator a committed unit: + +```python +committed = ms.override(spec, {'commitment': 'variants/commitment.yaml'}) +``` + +A patch is refused on its own, since it edits declarations it does not +declare. So the spec it lands on is the only place its math exists, and the +tab prints the patch beside that math. + + +```yaml +version: 0 +dimensions: + snapshot: {dtype: datetime, description: dispatch periods} + bus: {dtype: str, description: network nodes} + port: {dtype: str, description: 'the connections components make, one label per connection'} + generator: {dtype: str, description: 'generating units, each on one port'} + load: {dtype: str, description: 'demands, each on one port'} +relations: + Port_bus: {key: port, values: bus} + Generator_port: {key: generator, values: port} + Load_port: {key: load, values: port} +parameters: + Generator_p_nom: + dims: [generator] + dtype: float + description: nominal power + Generator_marginal_cost: + dims: [generator] + dtype: float + description: cost of one unit of output + Load_p_set: + dims: [snapshot, load] + dtype: float + description: '`Load-p_set` — what a load takes in a snapshot' +variables: + Port_p: + dims: [snapshot, port] + domain: continuous + absence: undefined + description: what a port puts into its bus in a snapshot, negative for a withdrawal + Generator_p: + dims: [snapshot, generator] + bounds: {lower: 0.0, upper: Generator_p_nom} + domain: continuous + absence: undefined + description: '`Generator-p` — what a generator produces in a snapshot' +constraints: + Bus_nodal_balance: + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 + description: '`Bus-nodal_balance` — what the ports on a bus put in nets to nothing' + Generator_injection: + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p + description: 'what a generator produces is what its port injects. No PyPSA row stands for this: PyPSA + writes the generator into the balance instead' + Load_withdrawal: + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set + description: 'what a load takes is what its port withdraws. No PyPSA row stands for this: PyPSA writes + the load into the balance instead' +objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_cost)} +``` + +=== "As composed" + + #### Sets + + | Symbol | Meaning | + |---|---| + | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | + | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | + | $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N},\ \mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J},\ \mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | + | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + + #### Parameters + + | Symbol | Meaning | + |---|---| + | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | + | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + + #### Variables + + | Symbol | Meaning | + |---|---| + | $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + | $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + #### Subject to + + **`Bus_nodal_balance`** + + ```math + \sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} + ``` + + **`Generator_injection`** + + ```math + f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Load_withdrawal`** + + ```math + f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} + ``` + + #### Variable domains + + **`Port_p`** + + ```math + f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} + ``` + + **`Generator_p`** + + ```math + 0 \le p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + +=== "With commitment" + + ```yaml title="variants/commitment.yaml" + parameters: + Generator_p_min_pu: { dims: [generator], description: "least output, per unit of nominal power" } + variables: + Generator_status: + dims: [snapshot, generator] + domain: binary + description: "`Generator-status` — whether a unit is on in a snapshot" + Generator_p: { bounds: { upper: null } } + constraints: + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most its nominal power; off, at most nothing" + dims: [snapshot, generator] + expression: Generator_p <= Generator_p_nom * Generator_status + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [snapshot, generator] + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + ``` + + #### Sets + + | Symbol | Meaning | + |---|---| + | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | + | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | + | $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N},\ \mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J},\ \mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | + | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + + #### Parameters + + | Symbol | Meaning | + |---|---| + | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | + | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + | $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{G}`$ — least output, per unit of nominal power | + + #### Variables + + | Symbol | Meaning | + |---|---| + | $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + | $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + | $`u`$ | `Generator_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-status` — whether a unit is on in a snapshot | + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + #### Subject to + + **`Bus_nodal_balance`** + + ```math + \sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} + ``` + + **`Generator_injection`** + + ```math + f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Load_withdrawal`** + + ```math + f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} + ``` + + **`Generator_com_p_upper`** + + ```math + p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Generator_com_p_lower`** + + ```math + p_{t,g} \ge \underline{\mathrm{p}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + #### Variable domains + + **`Port_p`** + + ```math + f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} + ``` + + **`Generator_p`** + + ```math + p_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Generator_status`** + + ```math + u_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + diff --git a/docs/examples/library/generator.md b/docs/examples/library/generator.md new file mode 100644 index 00000000..8d88fb78 --- /dev/null +++ b/docs/examples/library/generator.md @@ -0,0 +1,114 @@ + + +# Generators + +PyPSA's `Generator`, as one fragment. It owns its dimension, its relation into +`port`, its parameters, its column and its cost. It reads `Port_p` from +[the surface](surface.md) under +[`given`](../../reference/language/declarations.md#given). `Generator_port` +stands where PyPSA writes `Generator_bus`. + +The constraint is what makes the library composable. +`at(Port_p, by=Generator_port, over=port, into=generator)` pins the flow at +this component's own port rather than adding a term to the balance, so the +balance does not grow. + +The file is cut to what a dispatch spec needs. A fixed build, no availability +profile and no ramp limits are three declarations PyPSA carries and this file +does not. [The PyPSA rungs](../pypsa.md) state them in full. + +The math below is what this file prints on its own, with `Port_p` under +*Given* in the legend. When it merges with the surface, `Port_p` is one +declaration again. + + +```yaml +description: >- + PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut + to what a dispatch spec needs: a fixed build, no availability profile, no + ramp limits. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + generator: { dtype: str, description: "generating units, each on one port" } +relations: + Generator_port: { key: generator, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Generator_p_nom: { dims: [generator], description: nominal power } + Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } +variables: + Generator_p: + dims: [snapshot, generator] + bounds: { lower: 0, upper: Generator_p_nom } + description: "`Generator-p` — what a generator produces in a snapshot" +constraints: + Generator_injection: + description: >- + what a generator produces is what its port injects. No PyPSA row stands + for this: PyPSA writes the generator into the balance instead + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p +objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) +``` + +PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut to what a dispatch spec needs: a fixed build, no availability profile, no ramp limits. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — the connections components make, one label per connection | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | +| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — the surface introduces this flow, and this file pins it at its own ports | + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} +``` + +#### Subject to + +**`Generator_injection`** + +```math +f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`Generator_p`** + +```math +0 \le p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + diff --git a/docs/examples/library/index.md b/docs/examples/library/index.md new file mode 100644 index 00000000..fb7b1acd --- /dev/null +++ b/docs/examples/library/index.md @@ -0,0 +1,65 @@ + + +# A component library + +Several files each say part of a spec and compose into one. The surface +declares what the components share. Each component file declares its own math +against the surface, and [`merge`](../../howto/compose.md) makes the spec. +Every file here loads and prints on its own, so the unit you pick from is the +unit you can read. + +The names are PyPSA's, spelled `Component_attribute` as +[the PyPSA rungs](../pypsa.md) spell them, and the math prints in the symbols +those pages use. The spec is cut to dispatch: one build, no availability +profile, no ramp limits. + +## The layout + +```text +examples/library/ + surface.yaml one flow per port, one balance per bus + generator.yaml PyPSA's Generator + load.yaml PyPSA's Load + variants/ + commitment.yaml a patch over the composition, not a peer +``` + +| Page | What it shows | +| ---------------------------------- | -------------------------------------------------------------- | +| [The coupling surface](surface.md) | the surface, and the sign convention | +| [Generators](generator.md) | a file that reads `Port_p` and prices its output | +| [Loads](load.md) | a file with no variable of its own | +| [The composed spec](composed.md) | what `merge` returns, and the math it prints with each variant | + +## Rules of the layout + +- **One file per thing you would pick on its own.** `merge` takes a whole + fragment or none of it, so a spec with no storage never mentions storage. +- **Every component file is written against one surface.** +- **Every name carries the component class it belongs to.** `merge` does not + rename. `Generator_` and `Load_` keep the files apart, and the surface owns + `Port_`, `port` and `bus`. +- **A fragment is what a system has. A patch is how a component is + formulated.** A second kind of component is a peer, and `merge` composes it. + A different formulation of one component edits declarations that already + exist, and `override` lays it over the composition. + +## The variant + +`variants/commitment.yaml` makes the generator a committed unit. It adds a +binary, lifts the upper bound the capacity gave `Generator_p`, and caps output +with a constraint instead. + +It edits `Generator_p`, which `generator.yaml` introduces, and names +`Generator_p_nom`, which `generator.yaml` declares. So it is not a spec, and +`to_spec` refuses it on its own. It is laid over the composition: + +```python +ms.override(ms.merge(fragments), {'commitment': 'variants/commitment.yaml'}) +``` + +The [composed spec](composed.md) carries the patch and the math it makes, in a +tab of its own. That tab is the only place the patch can be read as math. diff --git a/docs/examples/library/load.md b/docs/examples/library/load.md new file mode 100644 index 00000000..ece3e87f --- /dev/null +++ b/docs/examples/library/load.md @@ -0,0 +1,69 @@ + + +# Loads + +PyPSA's `Load`, and the fragment that shows what a file may leave out. It +declares no variable and no objective. `Load_p_set` is data, and the only thing +the file says is what the load's port withdraws. + +The minus sign is the whole of its relation to the sign convention. A +withdrawal is a negative injection. + + +```yaml +description: PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + load: { dtype: str, description: "demands, each on one port" } +relations: + Load_port: { key: load, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } +constraints: + Load_withdrawal: + description: >- + what a load takes is what its port withdraws. No PyPSA row stands for + this: PyPSA writes the load into the balance instead + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set +``` + +PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | +| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — the surface introduces this flow, and this file pins it at its own ports | + +#### Subject to + +**`Load_withdrawal`** + +```math +f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} +``` + diff --git a/docs/examples/library/surface.md b/docs/examples/library/surface.md new file mode 100644 index 00000000..dc40573f --- /dev/null +++ b/docs/examples/library/surface.md @@ -0,0 +1,77 @@ + + +# The coupling surface + +The surface every other file in the library is written against. It declares one +`Port_p` per port, one balance per bus, and the relation that says which bus a +port sits on. Nothing in it names a component class, so it is the one file that +does not change when a component class is added. + +PyPSA gives each component class a bus column and sums the classes into +`Bus-nodal_balance`. Here a component is wired to a port and the port to a bus, +so the balance sums ports and stays as written however many fragments merge. +The [how-to guide](../../howto/compose.md) shows the same shape with fewer +names. + +A flow is positive where the port injects into its bus. Every component reads +that convention, and no component restates it. + + +```yaml +description: >- + The coupling surface every component in this library is written against: one + flow per port, and one balance per bus. A component is wired to a port, the + port to a bus, and the balance names no component class. A flow is positive + where the port injects into its bus. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + bus: { dtype: str, description: network nodes } + port: { dtype: str, description: "the connections components make, one label per connection" } +relations: + Port_bus: { key: port, values: bus } +variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot, negative for a withdrawal +constraints: + Bus_nodal_balance: + description: "`Bus-nodal_balance` — what the ports on a bus put in nets to nothing" + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 +``` + +The coupling surface every component in this library is written against: one flow per port, and one balance per bus. A component is wired to a port, the port to a bus, and the balance names no component class. A flow is positive where the port injects into its bus. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — the connections components make, one label per connection | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + +#### Subject to + +**`Bus_nodal_balance`** + +```math +\sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Port_p`** + +```math +f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} +``` + diff --git a/docs/howto/compose.md b/docs/howto/compose.md new file mode 100644 index 00000000..896e4dc7 --- /dev/null +++ b/docs/howto/compose.md @@ -0,0 +1,296 @@ + + +# Compose a spec from several files + +Build one spec out of files that each say part of it. `merge` composes +**fragments**: the files of a component library, each owning part of the math. +`override` lays **patches** over a **base**: the spec a framework ships, and +the change a project makes to it. Each loads every fragment and the base with +[`to_spec`](../reference/language/errors.md#what-to_spec-checks) before it +composes them, and hands back the composed spec loaded the same way. The two +compose as `override(merge({…}), {…})`. + +## A library of components + +1. **Write the coupling surface as a spec.** One flow per port, one balance + per bus. Nothing in it names a component class. + + ```yaml title="surface.yaml" + dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + port: { dtype: str } + relations: + Port_bus: { key: port, values: bus } + variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus + constraints: + Bus_balance: + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 + ``` + +2. **Write each component file against that surface.** It declares its own + dimension, its own math, and one relation into `port`. It names `Port_p` + under [`given`](../reference/language/declarations.md#given), because the + surface introduces that column and this file only reads it. + + ```yaml title="generator.yaml" + dimensions: + snapshot: { dtype: int } + port: { dtype: str } + generator: { dtype: str } + relations: + Generator_port: { key: generator, values: port } + given: + variables: + Port_p: { dims: [snapshot, port] } + parameters: + Generator_p_nom: { dims: [generator] } + Generator_marginal_cost: { dims: [generator] } + variables: + Generator_p: { dims: [snapshot, generator], bounds: { lower: 0, upper: Generator_p_nom } } + constraints: + Generator_injection: + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p + objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) + ``` + + ```yaml title="load.yaml" + dimensions: + snapshot: { dtype: int } + port: { dtype: str } + load: { dtype: str } + relations: + Load_port: { key: load, values: port } + given: + variables: + Port_p: { dims: [snapshot, port] } + parameters: + Load_p_set: { dims: [snapshot, load] } + constraints: + Load_withdrawal: + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set + ``` + + Each file loads on its own and prints as math on its own. + +3. **Merge the files you need.** Each fragment is given a name, and that name + is what a refusal calls it. The order the fragments are given in does not + change the spec. + + ```python + import mathspec as ms + + spec = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'load': 'load.yaml'}) + ``` + + `merge` folds each given declaration into the declaration that introduces + it, so `spec` declares `Port_p` once and carries no `given:`. The objectives + of the fragments are summed, each term in parentheses, in the order the + fragment names sort in. + +4. **Add a component class without touching the balance.** A component file + pins the flow at its own port rather than adding a term to the balance, so + `Bus_balance` is written once and stays as it is however many files are + merged. What grows is the data: which ports exist, and which bus each one + sits on. + +## What a fragment may share + +| The entry | What happens | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a name a `given:` entry marks `additive` | every fragment's declaration of it is a term: the terms are summed in fragment-name order, each in parentheses, and the marked entry is kept | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | + +## A name two fragments declare + +Fragments own their math, so a name two of them declare is refused, both +named. Here two files each say what a generator fleet is: + +```text +fragments 'gas' and 'coal' both declare the parameter 'Generator_p_nom'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the fragment once, and let the data carry both. Different math under one spelling is a rename: call one of them something else. +``` + +## A column read one way and introduced another + +What a fragment states about a column it reads has to agree with the fragment +that introduces the column. The reader may say less, such as the frame with no +`domain`, and may not say something else. Here the generator reads `Port_p` as +binary: + +```text +fragment 'generator' reads the given variable 'Port_p' as {'dims': ['snapshot', 'port'], 'domain': 'binary'}, where 'surface' introduces it as {'dims': ['snapshot', 'port'], 'domain': 'continuous', 'absence': 'undefined', 'description': 'what a port puts into its bus'}. A given declaration says the same as the declaration it is folded into, or less: restate the frame as the introducer declares it, or leave the field out. +``` + +Two fragments that both only read a column have to read it the same way, and +a difference is refused as it is for a dimension. + +## A fragment that does not load on its own + +A fragment is a whole spec, so `merge` loads each one before it composes +them. A fragment `to_spec` refuses is refused under its own name, with the +refusal `to_spec` gives. A sibling cannot make it load. Here the generator +declares `Generator_p` and reads it under `given:` as well: + +```text +fragment 'generator' does not load on its own. A fragment is a whole spec: it declares what it builds, and reads what a sibling builds under 'given:'. +Given variable 'Generator_p' collides with the variable of the same name. Names share one flat namespace — rename one of them. +``` + +## A base and its patches + +1. **Write the base as a spec**, and each patch as the change it makes. A + patch names only the fields it changes. A declaration a patch does not name + stays as the base wrote it. The base loads on its own, and `override` loads + it first. A patch is not a spec, so it is laid over as written, and the + patched spec is loaded after. + + ```yaml title="base.yaml" + dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + parameters: + capacity: { dims: [generator] } + cost: { dims: [generator] } + load: { dims: [snapshot] } + variables: + dispatch: { dims: [snapshot, generator], bounds: { lower: 0, upper: capacity } } + constraints: + power_balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + objective: + sense: minimize + expression: sum(dispatch * cost) + ``` + + ```yaml title="operate.yaml" + variables: + dispatch: { where: "capacity > 0" } + ``` + + ```yaml title="carbon.yaml" + parameters: + emission_rate: { dims: [generator] } + constraints: + emission_cap: + dims: [] + expression: sum(dispatch * emission_rate) <= 1000 + ``` + +2. **Lay the patches on the base.** Each patch is given a name, and that name + is what a refusal calls it. The patches must write different fields, so the + order they are given in cannot change the spec. + + ```python + import mathspec as ms + + spec = ms.override('base.yaml', {'carbon': 'carbon.yaml', 'operate': 'operate.yaml'}) + ``` + + `spec` declares `emission_cap` beside `power_balance`, and `dispatch` carries + the mask `capacity > 0`. + +3. **Remove a declaration with `null`.** A patch that does not mention a + declaration leaves it alone, so removal needs a marker of its own. + + ```yaml title="feasibility.yaml" + constraints: + emission_cap: null + objective: null + ``` + + A `null` makes what it names absent. On a declaration, the declaration is + removed. On a field, the field takes its default and the declaration stays: + `dispatch: { where: null }` gives that variable no mask, and + `dispatch: { bounds: { upper: null } }` leaves it open above. Higher up, + `constraints: null` is refused, because a section is not a declaration and + nulling it removes nothing. + +4. **Nest the calls where one patch refines another.** The second call lays + its patch on the first call's result, so the order is on the page. + + ```python + spec = ms.override(ms.override('base.yaml', {'pathway': 'pathway.yaml'}), {'project': 'project.yaml'}) + ``` + +## What a patch may say + +| The entry | What happens | +| ------------------------------------ | ----------------------------------------------------------------------------- | +| some fields of a declaration | those fields change, and the rest of the declaration stays | +| a whole declaration under a new name | it is added | +| `null` under a declaration's name | it is removed | +| `null` on a field of a declaration | the field takes its default, and the rest of the declaration stays | +| `null` under a section's name | it is refused | +| a dimension or a relation | it is added, or restated word for word as the base declares it | +| an entry under one kind of `given:` | it is edited, added or removed like any declaration, and the other kinds stay | +| `version`, `description` | the patch's value replaces the base's | + +## A partial entry on a missing name + +An entry naming some fields has to land on a declaration the base has. A +mistyped name is refused rather than read as a new declaration: + +```text +patch 'project' edits the constraint 'power_balnce', which its base does not declare. Did you mean 'power_balance'? A patch creates a declaration only by writing it whole, and this one is not: a constraint needs `expression`. +``` + +To add a constraint, write the whole constraint. To change one, spell its name +as the base spells it. + +## Two patches on one field + +Two patches writing one field is refused, both named: + +```text +patches 'pathway' and 'project': both write variables.dispatch.bounds.upper. Patches laid on one base are disjoint, so nothing decides which of two writes wins. Write the change in one patch, or lay one patch on the result of the other: override(override(base, {'pathway': …}), {'project': …}). +``` + +## A dimension redeclared + +A patch may add a dimension or a relation, and may restate one the base +declares. The restatement is word for word: half a declaration is a second +reading of the same name. Changing one under the expressions already written +over it is refused, and so is removing one: + +```text +patch 'relabelled' declares the dimension 'snapshot' as {'dtype': 'str'}, where its base declares {'dtype': 'int'}. A patch adjusts the math, not the coordinate space the math is already written over: restate the declaration word for word, leave it out, or give the patch a dimension of its own under a name of its own. +``` + +## A section set to `null` + +A `null` removes the declaration it names. A section holds declarations rather +than being one, so nulling a section is refused rather than read as emptying +it: + +```text +patch 'project' sets 'constraints' to null, which removes nothing: the removal marker names one declaration, and a section is not one. Remove the declarations one at a time, each under its own name, or leave the section out of the patch. +``` + +## A stale removal + +A removal says what the base has, so a removal of a declaration the base does +not have is refused with the near miss: + +```text +patch 'stale' removes the constraint 'power_balnce', which its base does not declare. A removal is a claim about what is there, so a stale one is a patch that no longer describes the spec it lands on. Did you mean 'power_balance'? +``` diff --git a/docs/reference/api.md b/docs/reference/api.md index 8a852f15..bd78e246 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -27,6 +27,22 @@ The module `mathspec.program` holds the classes a `Program` is made of. The show_root_toc_entry: true heading_level: 3 +## Composing + +[Compose a spec from several files](../howto/compose.md) shows both in use. + +::: mathspec.merge + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: mathspec.override + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + ## Typesetting ::: mathspec.to_latex diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 903af7f7..70b42029 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -35,6 +35,12 @@ be checked only then, and the language checks none itself. The docs never say "bind" for it, so that **bound** means one thing: a lower or upper limit on a variable ([variables](language/declarations.md#variables)). +**Provide** +: What the host model, the model a file is layered onto, does for a name under +`given:`. It holds a column or a row family of that name, on the same frame. A +consumer checks that the host provides each given name +([what a program does not build](reading.md#what-a-program-does-not-build)). + **Consumer** : A tool that reads a spec: an **engine** that attaches data and builds the rows a solver takes, a **renderer** such as the typesetter, or a **checker** diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 49e8c4f3..754f78f2 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -5,8 +5,9 @@ SPDX-License-Identifier: CC-BY-4.0 # Parameters, variables, constraints and the objective -These four blocks carry the math. Each takes an optional `description:`, free -text that the [typeset](../typeset.md#descriptions) legend prints. +These four blocks carry the math, and `given:` names what the math reads from +another file. Each takes an optional `description:`, free text that the +[typeset](../typeset.md#descriptions) legend prints. ## `parameters` @@ -81,6 +82,221 @@ and `upper: -rating` is refused. Ship the negated column as data. Equal bounds pin a variable ([fix a quantity](../../howto/pin-a-variable.md)). A pinned variable is still a variable. +## `given` + +`given:` holds what this file reads and does not build: data under +`parameters:`, columns under `variables:`, named expressions under +`expressions:`, and row families under `constraints:`. It takes those four keys +and no other. A file with a `given:` block loads and prints on its own. + +### `given: parameters` + +A given parameter is data this file reads and another file declares. + +```yaml +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } +given: + parameters: + gen_cost: { dims: [generator], description: what one unit of output costs } + gen_on: { dims: [generator], dtype: bool } + variables: + gen_p: { dims: [snapshot, generator] } +constraints: + off_units_idle: + dims: [snapshot, generator] + where: not gen_on + expression: gen_p <= 0 +objective: + sense: minimize + expression: sum(gen_p * gen_cost) +``` + +| Field | | | +| ------------- | ---------------------------------------------------- | --------------- | +| `dims` | required. The dimensions the parameter is indexed by | | +| `dtype` | `float`, `int`, `bool` or `str` | default `float` | +| `description` | free text | default `null` | + +A given parameter is read wherever a parameter is: in an expression, a `where` +and a bound. A name declared under both `parameters:` and +`given: parameters:` is refused. The typeset legend lists a given parameter +under _Given_. + +### `given: variables` + +A given variable is a column this file reads and another file introduces. + +```yaml +dimensions: + snapshot: { dtype: int } + port: { dtype: str } + generator: { dtype: str } +relations: + gen_port: { key: generator, values: port } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +given: + variables: + flow: + dims: [snapshot, port] + description: what a port puts into its bus +constraints: + gen_injects: + dims: [snapshot, generator] + expression: at(flow, by=gen_port, over=port, into=generator) == gen_p +``` + +| Field | | | +| ------------- | ------------------------------------------------- | -------------------- | +| `dims` | required. The dimensions the column is indexed by | | +| `domain` | `continuous`, `integer` or `binary` | default `continuous` | +| `description` | free text | default `null` | + +There is no `bounds` and no `where`. The file that introduces the column owns +both. + +An expression reads a given variable as it reads any other. A name declared +under both `variables:` and `given: variables:` is refused. The typeset legend +lists a given variable under _Given_, and prints no domain line for it. + +[`merge`](../../howto/compose.md#a-library-of-components) folds a given +declaration into the declaration of another fragment that introduces the name, +so a composed library carries none of them. The folded declaration is the +introducer's, and what the reader states has to say the same or less. + +Where nothing in this language introduces the column, the program carries the +declaration until a host model provides it +([what a program does not build](../reading.md#what-a-program-does-not-build)). + +### `given: constraints` + +A given constraint is a row family that another model builds. This file reads +its dual. + +```yaml +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + constraints: + balance: + dims: [snapshot, bus] + description: the host model clears each bus +expressions: + price: + expression: dual(balance) +``` + +| Field | | | +| ------------- | ------------------------------------------------- | -------------- | +| `dims` | required. The dimensions the row family runs over | | +| `description` | free text | default `null` | + +There is no `expression` and no `sense`. +`dual(name)` is the only place a given row family may be named, and the frame +gives the reported expression its dimensions. A name declared under both +`constraints:` and `given: constraints:` is refused. + +### `given: expressions` + +A given expression is a named expression this file reads and another file +defines. + +```yaml +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus +constraints: + balance: + dims: [snapshot, bus] + expression: injection == 0 +``` + +| Field | | | +| ------------- | -------------------------------------------------------------------------------------------- | --------------- | +| `dims` | required. The dimensions the expression runs over | | +| `additive` | `true` where the name is a sum other files add terms to ([a sum](#a-sum-other-files-add-to)) | default `false` | +| `description` | free text | default `null` | + +There is no body. This file reads the name as it reads a given variable: a +quantity over the frame, of degree one. A `where` does not read it, because a +mask is built before any variable exists. A name declared under both +`expressions:` and `given: expressions:` is refused, unless the entry is +marked `additive`. The typeset legend lists a given expression under _Given_. + +[`merge`](../../howto/compose.md#a-library-of-components) folds a given +expression into the definition of another fragment. The `dims` are an upper +bound: a body that carries a dimension they do not name is refused. A body over +fewer dimensions is folded, and the composed spec decides: it refuses a row +that would repeat across the missing dimension, and accepts one where another +term carries it. The composed spec holds the body to the rules of every place +this file reads it: a square of a given expression that is quadratic is +refused once folded. + +#### A sum other files add to + +`additive: true` says the name is a sum other files add terms to. One file +says it, on its `given:` entry, with the frame. Each file that adds a term +declares it as an ordinary named expression under that name. + +```yaml +# balance.yaml reads the sum +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + expressions: + injection: + dims: [snapshot, bus] + additive: true + description: what the components put into a bus +variables: + slack: { dims: [snapshot, bus] } +constraints: + balance: + dims: [snapshot, bus] + expression: injection + slack == 0 +``` + +```yaml +# fleet.yaml adds a term +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + generator: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +expressions: + injection: sum(gen_p, by=gen_bus, over=generator, into=bus) +``` + +Each file loads alone: the reader over a sum it does not build, and the +contributor over its own term. Other readers state the frame and nothing more. + +A file may carry the marked entry and declare a term too. Then it reads the +sum so far, which alone is its own term. The term is one `expression:`, and +carries no dimension the entry does not state; both are checked at load. The +entry then folds into the definition, and the typeset legend lists it under +_Definitions_, as a sum other files add terms to. + +[`merge`](../../howto/compose.md#a-library-of-components) sums every term of a +marked name, each in parentheses, in fragment-name order, and keeps the marked +entry, so a later merge adds more. The entry's description is the sum's. It +refuses a term written as `cases:`, a term over a dimension the entry does not +state, and a file that declares a term and reads the name without carrying the +marked entry: on its own that file reads its term, and composed it would read +the sum. Two terms of a name no entry marks are refused as a collision. A +marked name no file adds to stays under `given:`. + ## `constraints` One block is one rule. The name of the block is the name of the constraint. diff --git a/docs/reference/language/errors.md b/docs/reference/language/errors.md index f583148f..83ff9126 100644 --- a/docs/reference/language/errors.md +++ b/docs/reference/language/errors.md @@ -31,6 +31,7 @@ loads. | `kind` | The file has… | The advice says… | | --------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `never-an-axis` | a dimension nothing is indexed by, nothing aggregates into and no relation targets | remove it, or keep it knowingly if its declarations are still to come | +| `given` | a parameter, column, named expression or row family it reads and does not build ([given](declarations.md#given)) | the model this one is layered onto provides it | | `unbounded` | a variable that no constraint, set or curve uses, whose objective term pushes it towards a bound it does not have | give it a finite bound, or the constraint that was meant to define it | ```text diff --git a/docs/reference/language/file.md b/docs/reference/language/file.md index fb3fe806..d635d178 100644 --- a/docs/reference/language/file.md +++ b/docs/reference/language/file.md @@ -5,8 +5,8 @@ SPDX-License-Identifier: CC-BY-4.0 # File shape -A spec file is a YAML mapping with **eleven declaration keys**, plus -`version` and `description`. Any subset of the eleven is accepted. +A spec file is a YAML mapping with **twelve declaration keys**, plus +`version` and `description`. Any subset of the twelve is accepted. | Key | | | ------------- | ------------------------------------------------------------------------------------------------- | @@ -14,6 +14,7 @@ A spec file is a YAML mapping with **eleven declaration keys**, plus | `relations` | named relations between dimensions ([relations](relations.md)) | | `parameters` | the data the spec expects ([declarations](declarations.md)) | | `variables` | what the solver decides | +| `given` | what this file reads but does not build ([given](declarations.md#given)) | | `constraints` | the rules those decisions obey | | `objective` | what is minimised or maximised | | `expressions` | named quantities, reusable in the math and readable after a solve ([named expressions](named.md)) | diff --git a/docs/reference/language/index.md b/docs/reference/language/index.md index cc23aa5c..db513116 100644 --- a/docs/reference/language/index.md +++ b/docs/reference/language/index.md @@ -46,7 +46,7 @@ names the fix. | # | Rule | | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| 1 | A file has eleven declaration keys, plus `version` and `description`. An unknown key is refused, with the nearest valid key named. | [File shape](file.md) | +| 1 | A file has twelve declaration keys, plus `version` and `description`. An unknown key is refused, with the nearest valid key named. | [File shape](file.md) | | 2 | Everything that can be checked without data is checked when the file loads. | [Errors](errors.md) | | 3 | Every name is declared once. A parameter and a dimension both called `snapshot` is refused. | [Names](expressions.md#name-resolution) | | 4 | Where a name may stand depends on what it is. A dimension follows `over=` or `along=`, and is never multiplied. | [Names](expressions.md#name-resolution) | diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 10672c07..aebf981f 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -162,6 +162,44 @@ and the relation a `PulledBackPredicate` reads is in its `.names_read`. and `~`, `&` and `|` combine masks into a mask. A mask folds as it is built, so a boolean literal stands at a mask's root or nowhere. A `Region`'s `when` is a `Mask` too. +## What a program does not build + +`program.given.parameters`, `program.given.variables`, +`program.given.expressions` and `program.given.constraints` name what the spec +reads and does not build ([given](language/declarations.md#given)). Every +other group is a build instruction. These four are names to look up in the +model this one is layered onto. An expression reads a given expression as a +`Variable` of that name, over the frame under `program.given.expressions`. +A given expression with `additive` set is a sum other files add terms to. +Where the file also declares a term of it, the name is a definition with +`additive` set under `program.expressions`, and not a given name. + +```python +layer = to_spec( + { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus']}}, + }, + 'parameters': {'rate': {'dims': ['bus']}}, + 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, + } +).program + +sorted(layer.variables) # [] +sorted(layer.given.variables) # ['p'] +layer.given.constraints['balance'].dims # ('snapshot', 'bus') +``` + +The host model provides each name: it holds a column or a row family of that +name. A consumer that builds the program checks that the host provides each +name on the same frame, and refuses the program where it does not. A consumer +with no host refuses a program whose four groups are not all empty. `advice` +returns one note of kind `given` per name +([what `advice` warns about](language/errors.md#what-advice-warns-about)). + ## Asking what a program uses `program.footprint` says which of the language's constructs one program uses. diff --git a/docs/several-files.md b/docs/several-files.md new file mode 100644 index 00000000..ff0c7bde --- /dev/null +++ b/docs/several-files.md @@ -0,0 +1,301 @@ + + +# A spec in several files + +In this lesson you build a dispatch spec out of files that each hold one part +of it, merge them into one spec, and then add a component without changing the +other files. Do [your first spec](first-spec.md) first. + +## The network + +Make a file `network.yaml`. It balances every bus, and it reads the injection +at a bus under [`given:`](reference/language/declarations.md#given) rather +than defining it. `additive: true` says that other files add terms to it: + +```yaml title="network.yaml" +description: Every bus is balanced in every snapshot. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + bus: { description: network nodes } + +given: + expressions: + injection: + dims: [snapshot, bus] + additive: true + description: what the components put into a bus, less what they take out + +constraints: + balance: + dims: [snapshot, bus] + expression: injection == 0 +``` + +Check the file: + +```bash +python -m mathspec check network.yaml +``` + +The check accepts it, and notes that no file has added a term yet: + +```text +expression 'injection' is a sum other files add terms to, and this file adds none: merge() sums the term every fragment declares under the name. Until one does, the program reads it and does not build it. +``` + +## The generators + +Make a file `generators.yaml`. Its term of the injection is an ordinary named +expression under the same name: + +```yaml title="generators.yaml" +description: A generator fleet, each unit on one bus. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + bus: { description: network nodes } + generator: { description: generating units } + +relations: + gen_bus: { key: generator, values: bus, description: the bus a generator sits on } + +parameters: + capacity: { dims: [generator], description: installed capacity } + cost: { dims: [generator], description: marginal cost } + +variables: + dispatch: + description: output of a generator in a snapshot + dims: [snapshot, generator] + bounds: { lower: 0, upper: capacity } + +expressions: + injection: sum(dispatch, by=gen_bus, over=generator, into=bus) + +objective: + sense: minimize + expression: sum(dispatch * cost) +``` + +Check the file. The check prints nothing and exits with status 0: + +```bash +python -m mathspec check generators.yaml +``` + +## The loads + +Make a file `loads.yaml`. Its term takes the demand out of the bus: + +```yaml title="loads.yaml" +description: The demand at every bus. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + bus: { description: network nodes } + +parameters: + demand: { dims: [snapshot, bus], description: demand to be met } + +expressions: + injection: -demand +``` + +Check the file. The check prints nothing and exits with status 0: + +```bash +python -m mathspec check loads.yaml +``` + +## Merge the files + +Merge the three files in Python. Each name is what an error calls that file: + +```python +import mathspec as ms + +spec = ms.merge({'network': 'network.yaml', 'generators': 'generators.yaml', 'loads': 'loads.yaml'}) +print(spec.expressions['injection'].expression) +``` + +The injection is the sum of the two terms, in the order of the file names: + +```text +(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (-demand) +``` + +Print the math of the merged spec: + +```python +print(ms.to_markdown(spec, legend=False)) +``` + +!!! example "Rendered output" + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} + ``` + + #### Subject to + + **`balance`** + + ```math + \mathit{injection}_{t,b} = 0 \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + #### Definitions + + **`injection`** + + ```math + \mathit{injection}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} - \mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + #### Variable domains + + **`dispatch`** + + ```math + 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + +## Add a component + +Make a file `imports.yaml`. It adds a term to the injection and a term to the +objective: + +```yaml title="imports.yaml" +description: Power bought from outside the network, at a price. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + bus: { description: network nodes } + +parameters: + import_limit: { dims: [bus], description: most a bus can import } + import_price: { dims: [snapshot], description: price of imported power } + +variables: + imported: + description: power a bus imports in a snapshot + dims: [snapshot, bus] + bounds: { lower: 0, upper: import_limit } + +expressions: + injection: imported + +objective: + sense: minimize + expression: sum(imported * import_price) +``` + +Merge the four files: + +```python +spec = ms.merge( + {'network': 'network.yaml', 'generators': 'generators.yaml', 'loads': 'loads.yaml', 'imports': 'imports.yaml'} +) +print(spec.expressions['injection'].expression) +print(spec.objective.expression) +``` + +The injection has a third term, and the objective sums the two objectives. +`network.yaml` did not change: + +```text +(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (imported) + (-demand) +(sum(dispatch * cost)) + (sum(imported * import_price)) +``` + +## Read what another file declares + +Make a file `emissions.yaml`. It caps what the fleet emits, and it reads +`dispatch` under `given:` rather than declaring it: + +```yaml title="emissions.yaml" +description: A cap on what the fleet emits over the horizon. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { description: generating units } + +given: + variables: + dispatch: { dims: [snapshot, generator] } + +parameters: + emission_rate: { dims: [generator], description: emissions per unit of output } + emission_cap: { dims: [], description: most the fleet may emit } + +constraints: + emission_limit: + dims: [] + expression: sum(dispatch * emission_rate) <= emission_cap +``` + +Check the file: + +```bash +python -m mathspec check emissions.yaml +``` + +The check accepts it, and notes the variable it reads: + +```text +variable 'dispatch' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces. +``` + +Merge all five files: + +```python +spec = ms.merge( + { + 'network': 'network.yaml', + 'generators': 'generators.yaml', + 'loads': 'loads.yaml', + 'imports': 'imports.yaml', + 'emissions': 'emissions.yaml', + } +) +print(sorted(spec.constraints)) +print(bool(spec.program.given)) +``` + +The cap reads the generators' `dispatch`, and nothing is left for anything +outside the files to provide: + +```text +['balance', 'emission_limit'] +False +``` + +## Leave the network out + +Merge the generators and the loads without the network: + +```python +ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) +``` + +`merge` refuses it. Without the file that marks `injection` as a sum, the two +terms are two definitions of one name: + +```text +fragments 'generators' and 'loads' both declare the expression 'injection'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the fragment once, and let the data carry both. Different math under one spelling is a rename: call one of them something else. If each fragment adds a term to one sum, mark the name `additive: true` on a `given: expressions:` entry in the file that reads it. +``` + +## Where to next + +- [Compose a spec from several files](howto/compose.md) covers `merge` and + `override`, which lays a patch over a spec. +- [`given`](reference/language/declarations.md#given) gives every rule a file + that reads another file obeys. +- [A component library](examples/library/index.md) shows larger fragments + beside the math they print. diff --git a/examples/library/generator.yaml b/examples/library/generator.yaml new file mode 100644 index 00000000..fd37bf26 --- /dev/null +++ b/examples/library/generator.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut + to what a dispatch spec needs: a fixed build, no availability profile, no + ramp limits. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + generator: { dtype: str, description: "generating units, each on one port" } +relations: + Generator_port: { key: generator, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Generator_p_nom: { dims: [generator], description: nominal power } + Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } +variables: + Generator_p: + dims: [snapshot, generator] + bounds: { lower: 0, upper: Generator_p_nom } + description: "`Generator-p` — what a generator produces in a snapshot" +constraints: + Generator_injection: + description: >- + what a generator produces is what its port injects. No PyPSA row stands + for this: PyPSA writes the generator into the balance instead + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p +objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) diff --git a/examples/library/load.yaml b/examples/library/load.yaml new file mode 100644 index 00000000..91f194df --- /dev/null +++ b/examples/library/load.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +description: PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + load: { dtype: str, description: "demands, each on one port" } +relations: + Load_port: { key: load, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } +constraints: + Load_withdrawal: + description: >- + what a load takes is what its port withdraws. No PyPSA row stands for + this: PyPSA writes the load into the balance instead + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set diff --git a/examples/library/surface.yaml b/examples/library/surface.yaml new file mode 100644 index 00000000..cc568f2c --- /dev/null +++ b/examples/library/surface.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + The coupling surface every component in this library is written against: one + flow per port, and one balance per bus. A component is wired to a port, the + port to a bus, and the balance names no component class. A flow is positive + where the port injects into its bus. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + bus: { dtype: str, description: network nodes } + port: { dtype: str, description: "the connections components make, one label per connection" } +relations: + Port_bus: { key: port, values: bus } +variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot, negative for a withdrawal +constraints: + Bus_nodal_balance: + description: "`Bus-nodal_balance` — what the ports on a bus put in nets to nothing" + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 diff --git a/examples/library/variants/commitment.yaml b/examples/library/variants/commitment.yaml new file mode 100644 index 00000000..16ea0551 --- /dev/null +++ b/examples/library/variants/commitment.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +parameters: + Generator_p_min_pu: { dims: [generator], description: "least output, per unit of nominal power" } +variables: + Generator_status: + dims: [snapshot, generator] + domain: binary + description: "`Generator-status` — whether a unit is on in a snapshot" + Generator_p: { bounds: { upper: null } } +constraints: + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most its nominal power; off, at most nothing" + dims: [snapshot, generator] + expression: Generator_p <= Generator_p_nom * Generator_status + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [snapshot, generator] + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status diff --git a/examples/symbols/library.yaml b/examples/symbols/library.yaml new file mode 100644 index 00000000..247defa8 --- /dev/null +++ b/examples/symbols/library.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +# How `examples/library/` prints. The names are PyPSA's, so a fragment reads +# beside `n.model`; these symbols are the ones `pypsa.yaml` uses. One table +# serves every fragment, the spec they compose and the variants laid over it. +# Each page takes the cut of it that its own spec declares, because a table +# naming anything else is refused. +notation: latex +dimensions: + snapshot: { index: t, set: '\mathcal{T}' } + bus: { index: n, set: '\mathcal{N}' } + port: { index: j, set: '\mathcal{J}' } + generator: { index: g, set: '\mathcal{G}' } + load: { index: d, set: '\mathcal{D}' } +names: + Port_p: f + Generator_p: p + Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' + Generator_marginal_cost: '\mathrm{c}' + Generator_p_min_pu: '\underline{\mathrm{p}}' + Generator_status: u + Load_p_set: '\mathrm{load}' diff --git a/mkdocs.yml b/mkdocs.yml index b5358127..273db786 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: # section, after the pages a reader looks things up in. - Tutorials: - Your first spec: first-spec.md + - A spec in several files: several-files.md - How-to guides: - Installation: howto/installation.md - Check a spec without data: howto/check.md @@ -43,6 +44,7 @@ nav: - Write a piecewise curve out by hand: howto/curve-by-hand.md - See what a curve or a set expands to: howto/see-an-expansion.md - Compare two specs: howto/compare.md + - Compose a spec from several files: howto/compose.md - Reference: - Language: - reference/language/index.md @@ -65,6 +67,12 @@ nav: - Least-cost dispatch: examples/dispatch.md - Unit commitment: examples/commitment.md - One construct per spec: examples/operators.md + - A component library: + - examples/library/index.md + - The coupling surface: examples/library/surface.md + - Generators: examples/library/generator.md + - Loads: examples/library/load.md + - The composed spec: examples/library/composed.md - About: - The limits: about/limits.md - Versions: about/versions.md diff --git a/pyproject.toml b/pyproject.toml index f14e2e50..240fa70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -211,12 +211,13 @@ extend-exclude = ["CHANGELOG.md"] [tool.typos.default.extend-words] # Each of these is deliberate, and none is reachable by fixing a spelling. # -# `wher` and `generatr` are misspellings *on purpose*: they are the input to the -# did-you-mean suggester, so correcting them deletes the case. `missable` is the +# `wher`, `generatr` and `balnce` are misspellings *on purpose*: they are the +# input to the did-you-mean suggester, so correcting them deletes the case. `missable` is the # word the sentence wants — a coordinate that can go missing — and is not # "miscible". `unparseable` is the accepted variant and is the name of a test. wher = "wher" generatr = "generatr" +balnce = "balnce" missable = "missable" unparseable = "unparseable" # `fom` is PyPSA's `fom_cost` — fixed operation and maintenance — quoted by name diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index c29c3400..27b6d7d9 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -270,6 +270,192 @@ "title": "ExpressionCase", "type": "object" }, + "GivenBlock": { + "additionalProperties": false, + "description": "What this file reads and does not build, by kind. Closed at the four kinds.", + "properties": { + "constraints": { + "additionalProperties": { + "$ref": "#/$defs/GivenConstraintBlock" + }, + "default": {}, + "title": "Constraints", + "type": "object" + }, + "expressions": { + "additionalProperties": { + "$ref": "#/$defs/GivenExpressionBlock" + }, + "default": {}, + "title": "Expressions", + "type": "object" + }, + "parameters": { + "additionalProperties": { + "$ref": "#/$defs/GivenParameterBlock" + }, + "default": {}, + "title": "Parameters", + "type": "object" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/GivenVariableBlock" + }, + "default": {}, + "title": "Variables", + "type": "object" + } + }, + "title": "GivenBlock", + "type": "object" + }, + "GivenConstraintBlock": { + "additionalProperties": false, + "description": "A row family this file reads the dual of and another model builds.\n\nThe frame says how many duals there are and what indexes them, which is\nwhat ``dual()`` needs. There is no ``expression:``, because nothing here\nbuilds a row.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + } + }, + "required": [ + "dims" + ], + "title": "GivenConstraintBlock", + "type": "object" + }, + "GivenExpressionBlock": { + "additionalProperties": false, + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``additive: true`` says the name is a sum other files add terms to. Each\nof them declares its term as an ordinary named expression under the name,\nand [`merge`][mathspec.composition.merge] sums the terms. A file that marks\nthe name may declare a term of its own too, and then reads the sum so far.", + "properties": { + "additive": { + "default": false, + "title": "Additive", + "type": "boolean" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + } + }, + "required": [ + "dims" + ], + "title": "GivenExpressionBlock", + "type": "object" + }, + "GivenParameterBlock": { + "additionalProperties": false, + "description": "Data this file reads and another file declares.\n\nIt says what a [`ParameterBlock`][] says, because the frame and the\ndtype are all a parameter declaration holds: a where compares against the\ndtype, and the dim rules read the frame.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "dtype": { + "default": "float", + "enum": [ + "float", + "int", + "bool", + "str" + ], + "title": "Dtype", + "type": "string" + } + }, + "required": [ + "dims" + ], + "title": "GivenParameterBlock", + "type": "object" + }, + "GivenVariableBlock": { + "additionalProperties": false, + "description": "A column this file reads and another file introduces.\n\nThe frame and the domain are all this file states. The file that introduces\nthe column owns its bounds and its mask.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "domain": { + "default": "continuous", + "enum": [ + "continuous", + "integer", + "binary" + ], + "title": "Domain", + "type": "string" + } + }, + "required": [ + "dims" + ], + "title": "GivenVariableBlock", + "type": "object" + }, "MacroBlock": { "additionalProperties": false, "description": "A parameterised expression template, defined in the YAML itself.\n\nLanguage, not code: formals (``args`` positional, ``kwargs`` keyword)\nshadow the spec's names inside the template, and every call site expands in\nthe syntax tree before resolution reads the expression.", @@ -681,7 +867,7 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, [`model_validate`][], the constructor \u2014 runs\nevery load-time check, expression pass included, and raises\n[`LanguageError`][] on a spec the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the eleven declaration sections plus ``version`` and\n``description``, three ways back out \u2014 [`to_dict`][] for the spec as\ndata, [`to_yaml`][] for the file a reviewer reads, [`expand`][] for the\nspec with its formulations written out as plain rows \u2014 and [`program`][], the\nspec typed, which every reader after load walks. Everything else on this\nclass is pydantic's, not a contract this package keeps.", + "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, [`model_validate`][], the constructor \u2014 runs\nevery load-time check, expression pass included, and raises\n[`LanguageError`][] on a spec the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the twelve declaration sections plus ``version`` and\n``description``, three ways back out \u2014 [`to_dict`][] for the spec as\ndata, [`to_yaml`][] for the file a reviewer reads, [`expand`][] for the\nspec with its formulations written out as plain rows \u2014 and [`program`][], the\nspec typed, which every reader after load walks. Everything else on this\nclass is pydantic's, not a contract this package keeps.", "properties": { "assumptions": { "additionalProperties": { @@ -727,6 +913,15 @@ "title": "Expressions", "type": "object" }, + "given": { + "$ref": "#/$defs/GivenBlock", + "default": { + "constraints": {}, + "expressions": {}, + "parameters": {}, + "variables": {} + } + }, "macros": { "additionalProperties": { "$ref": "#/$defs/MacroBlock" diff --git a/src/mathspec/__init__.py b/src/mathspec/__init__.py index 1922c1ee..505b6214 100644 --- a/src/mathspec/__init__.py +++ b/src/mathspec/__init__.py @@ -13,6 +13,7 @@ from mathspec import program from mathspec.advising import advice +from mathspec.composition import merge, override from mathspec.errors import Advice, AdviceKind, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean from mathspec.operators import BUILTIN_NAMES from mathspec.spec import Spec @@ -40,6 +41,8 @@ 'SymbolTable', 'advice', 'did_you_mean', + 'merge', + 'override', 'program', 'to_latex', 'to_markdown', diff --git a/src/mathspec/advising.py b/src/mathspec/advising.py index cb7ace18..1347c7d5 100644 --- a/src/mathspec/advising.py +++ b/src/mathspec/advising.py @@ -35,15 +35,53 @@ def advice(spec: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Ad nothing expanded. Returns: - The never-an-axis advice in declaration order, then the unboundedness - advice; ``str()`` of each is its sentence. + The never-an-axis advice in declaration order, then one note per + declaration the program reads and does not build, then the + unboundedness advice; ``str()`` of each is its sentence. Raises: LanguageError: *spec* does not load; [`to_spec`][] says why. FileNotFoundError: A ``str`` with no newline that names no file. """ program = spec if isinstance(spec, Program) else to_spec(spec).program - return tuple(_never_an_axis(program) + unbounded_notes(program)) + return tuple(_never_an_axis(program) + _given(program) + unbounded_notes(program)) + + +def _given(program: Program) -> list[Advice]: + """One note per declaration the program reads and does not build. + + A note rather than a refusal: the file is a spec somebody meant, and only + the consumer can tell whether the model it is layered onto provides the + name. A sum other files add terms to is completed by `merge` rather than + by a host, so its note says that instead. + """ + return [ + Advice('given', name, _sum_note(name) if getattr(block, 'additive', False) else _given_note(kind, name)) + for kind, group in ( + ('parameter', program.given.parameters), + ('variable', program.given.variables), + ('expression', program.given.expressions), + ('row family', program.given.constraints), + ) + for name, block in group.items() + ] + + +def _given_note(kind: str, name: str) -> str: + return ( + f"{kind} '{name}' is read here and declared elsewhere: the model this one is layered onto " + f'provides it. A consumer checks that it does, on the same frame, and refuses the program where ' + f'it does not. A fragment is composed instead: merge() folds this declaration into the one a ' + f'sibling introduces.' + ) + + +def _sum_note(name: str) -> str: + return ( + f"expression '{name}' is a sum other files add terms to, and this file adds none: merge() sums the " + f'term every fragment declares under the name. Until one does, the program reads it and does not ' + f'build it.' + ) def _never_an_axis(program: Program) -> list[Advice]: @@ -51,10 +89,20 @@ def _never_an_axis(program: Program) -> list[Advice]: A dimension a relation has a column over is reached: its members are the labels that column is checked against, and a ``where`` selects on them, - so it is in use even where nothing is indexed by it. + so it is in use even where nothing is indexed by it. A dimension only a + given declaration indexes is reached too: the column exists, in another + file. """ reached: set[str] = set() - for declaration in (*program.parameters.values(), *program.variables.values(), *program.constraints.values()): + for declaration in ( + *program.parameters.values(), + *program.variables.values(), + *program.constraints.values(), + *program.given.parameters.values(), + *program.given.variables.values(), + *program.given.expressions.values(), + *program.given.constraints.values(), + ): reached.update(declaration.dims) reached |= _produced_axes(program) reached |= {dim for lk in program.relations.values() for dim in lk.dims} diff --git a/src/mathspec/boundedness.py b/src/mathspec/boundedness.py index 47c7d43b..c95d69eb 100644 --- a/src/mathspec/boundedness.py +++ b/src/mathspec/boundedness.py @@ -76,7 +76,7 @@ def unbounded_notes(program: Program) -> list[Advice]: minimize = program.objective.sense == 'minimize' notes: list[Advice] = [] for vname, sign in signs.items(): - if sign is None or vname in constrained: + if sign is None or vname in constrained or vname in program.given.variables: continue side: BoundSide = 'lower' if minimize == (sign == '+') else 'upper' if _is_open(program.variables[vname], side): diff --git a/src/mathspec/canonical.py b/src/mathspec/canonical.py index 6136504a..8198d3a8 100644 --- a/src/mathspec/canonical.py +++ b/src/mathspec/canonical.py @@ -222,8 +222,8 @@ def _sorted_blocks(section: dict[str, object], *, bare_is_expression: bool = Fal def canonical_dict(spec: Spec) -> dict[str, object]: """The spec as plain data, in the form two files that state the same spec share. - Declarations are sorted by name and every expression is printed from its - parsed tree, so what is left of a difference is a difference in the spec. + Declarations are sorted by name, the ones under each kind of ``given:`` + too, and every expression is printed from its parsed tree, so what is left of a difference is a difference in the spec. A ``where`` string, the order of a ``cases:`` block's regions, the order of a declaration's ``dims`` and the order of a piecewise block's links are all left as written. @@ -239,7 +239,9 @@ def canonical_dict(spec: Spec) -> dict[str, object]: data = spec.to_dict() built: dict[str, object] = {} for section, value in data.items(): - if isinstance(value, dict) and section != 'objective': + if section == 'given' and isinstance(value, dict): + built[section] = {kind: _sorted_blocks(entries) for kind, entries in sorted(value.items())} + elif isinstance(value, dict) and section != 'objective': built[section] = _sorted_blocks(value, bare_is_expression=section == 'expressions') else: built[section] = _canonical_block(value) diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py new file mode 100644 index 00000000..f476e448 --- /dev/null +++ b/src/mathspec/composition.py @@ -0,0 +1,791 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Several files into one spec, each checked as a spec where it is one. + +Two verbs, and they answer different questions. [`merge`][] composes +**peers**: fragments that each own part of the math, where a name two of them +declare is a collision and the order they are given in means nothing. +[`override`][] lays **patches** over a **base**: what a framework ships and a +project extends, where a name the patch declares is the point. They compose as +``override(merge({...}), {...})``, which builds the spec and then configures +the run. + +What [`merge`][] does with each section: + +* **A dimension or a relation every fragment may declare**, and the ones that + do have to say the same thing about it. Prose is not a claim, so two + descriptions of one dimension agree, and the first fragment's is carried. +* **Every other declaration is owned.** A name two fragments declare is refused, + both named. +* **The objectives are summed**, each term in parentheses, in the fragments' + name order, and the senses have to agree. +* **A sum is summed the same way.** A ``given: expressions:`` entry marked + ``additive: true`` says the name is a sum other files add terms to. Every + fragment's declaration of it is a term, and the composed spec declares their + sum and keeps the marked entry, so a later merge adds more. A term may carry + no dimension the entry does not state, and is one ``expression:``. A + fragment that declares a term and reads the name without carrying the marked + entry is refused: on its own it reads its term, and composed it would read + the sum. +* **A given declaration is folded** into the declaration that introduces the + name, once the reader is checked to say the same as the introducer or less. + A given expression's body may carry no dimension its reader does not state, + and a name read as one kind and introduced as another is refused. + Two fragments that both only read a name have to read it the same way, and a + fragment that declares a name and reads it as well is refused. What no + fragment introduces stays under ``given:`` until a host model provides it. + +A patch says only what it changes, because declarations are laid over a field +at a time:: + + constraints: + ramp: {dims: [snapshot, generator, investment_period]} + +A fragment is a [`Spec`][mathspec.spec.Spec] of its own, and a base is one +too: each goes through [`to_spec`][mathspec.validation.to_spec] before anything is +composed, so a composed spec never hides a file that does not load alone. A +patch is not one. It names only what it changes and may carry ``null`` where a +declaration would go, so it is laid over as written, and the result goes +through [`to_spec`][mathspec.validation.to_spec] like any other file. + +What a patch may say, and what is refused: + +* **A partial entry edits, and a whole one creates.** An entry that does not + validate as a declaration on its own has to land on one the base declares, + and a miss is refused with the near miss named. +* **Sibling patches are disjoint.** Two patches writing one field is refused, + both named, so the order they are given in never decides a spec. Layering + is written out as ``override(override(base, …), …)``. +* **A patch adjusts the math, not the coordinate space.** A ``dimensions`` or + ``relations`` entry may be added or restated word for word, never changed and + never removed. +* ``null`` **makes what it names absent.** A declaration set to ``null`` is + removed, and a removal of what the base does not declare is refused. A field + set to ``null`` is dropped, and takes its default when the result loads: + ``variables: {p: {bounds: {upper: null}}}`` opens that bound. A whole section + set to ``null`` is refused, because it removes nothing. +* **``given:`` is laid over one kind at a time**, by the same rules as any + owned section. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from typing import TYPE_CHECKING, cast, get_args, get_origin + +from pydantic import BaseModel, ValidationError + +from mathspec._yaml import read_spec +from mathspec.errors import LanguageError, did_you_mean, schema_error +from mathspec.program import Named, walk +from mathspec.spec import GivenBlock, Spec +from mathspec.validation import to_spec + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + +#: The declarations that are the coordinate space rather than the math. A patch +#: may add one, and may restate one its base already declares word for word; it +#: may not say something else about it, and it may not remove it. +SHARED_SECTIONS = ('dimensions', 'relations') + +#: The declarations a patch edits, creates or removes: every other section of +#: the schema keyed by declaration name, read off it so that none is left out. +OWNED_SECTIONS = tuple( + name + for name, field in Spec.model_fields.items() + if get_origin(field.annotation) is dict and name not in SHARED_SECTIONS +) + +#: What ``given:`` holds, by the key each kind sits under and what one entry of +#: it is called. The key is the introducing section's name too, which is what +#: lets [`merge`][] fold a given declaration into the one that introduces it. +GIVEN_KINDS = { + 'parameters': 'given parameter', + 'variables': 'given variable', + 'constraints': 'given constraint', + 'expressions': 'given expression', +} + +#: Every section keyed by declaration name. ``objective`` is one declaration +#: rather than a mapping of them, and is laid over field by field beside these. +SECTIONS = (*SHARED_SECTIONS, *OWNED_SECTIONS, 'given') + +#: What one entry is called where dropping the key's last letter does not say it. +IRREGULAR = { + 'piecewise': 'piecewise curve', + 'sos': 'special-ordered set', + 'objective': 'objective', +} + + +def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], description: str | None = None) -> Spec: + """*fragments* composed as peers, each owning the math it declares. + + Args: + fragments: What each fragment is called, to the fragment: a YAML path, + YAML text, a mapping, or a loaded [`Spec`][mathspec.spec.Spec]. + The name is what an error calls it. The order they are given in + does not reach the result. + description: What the composed spec is. A fragment's own + ``description`` is about the fragment, and is not carried. + + Returns: + The composed spec, loaded. A given declaration a sibling introduces is + folded away; one nothing introduces stays under ``given:``. + + Raises: + LanguageError: A fragment does not load on its own; two fragments + declare one name; two fragments say different things about one + dimension, relation or given declaration; a fragment reads a name as + something other than what its sibling introduces, as another kind + of thing, or over fewer dimensions than its body carries; two fragments are + written against different language versions; their objectives run + opposite ways; or the composed spec does not load. + FileNotFoundError: A ``str`` with no newline that names no file. + """ + loaded = {name: _fragment(name, fragment) for name, fragment in fragments.items()} + read = {name: spec.to_dict() for name, spec in loaded.items()} + merged: dict[str, object] = {'version': _one_version(read)} + if description is not None: + merged['description'] = description + for section in SHARED_SECTIONS: + if agreed := _agreed(read, section, _singular(section)): + merged[section] = agreed + asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} + readings = _agreed_readings(asked) + sums = {key: entry for key, entry in readings.items() if entry['additive']} + for section in OWNED_SECTIONS: + if claimed := _claimed(read, section, sums if section == 'expressions' else {}): + merged[section] = claimed + if summed := _summed_terms(read, loaded, sums): + merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} + if given := _folded(read, merged, loaded, readings): + merged['given'] = given + if (objective := _summed_objective(read)) is not None: + merged['objective'] = objective + return to_spec(merged) + + +def _fragment(name: str, source: str | Path | Mapping[str, object] | Spec) -> Spec: + """One fragment loaded as the spec it is on its own, refused under its own name where it is not one.""" + try: + return to_spec(source) + except LanguageError as e: + msg = ( + f"fragment '{name}' does not load on its own. A fragment is a whole spec: it declares " + f"what it builds, and reads what a sibling builds under 'given:'.\n{e}" + ) + raise type(e)(msg) from None + + +def _one_version(read: Mapping[str, dict[str, object]]) -> int: + """The language version every fragment is written against.""" + declared = {name: cast('int', sections['version']) for name, sections in read.items()} + if len(set(declared.values())) > 1: + spelled = ', '.join(f"'{name}' says {version}" for name, version in sorted(declared.items())) + raise LanguageError( + f'the fragments are written against different language versions: {spelled}. One spec has ' + f'one version, so write every fragment against the same one.' + ) + return next(iter(declared.values()), 0) + + +def _author_of(read: Mapping[str, dict[str, object]], section: str, key: str) -> str: + """The first fragment declaring *key* under *section*, for a message that names both sides.""" + return next(name for name, sections in read.items() if key in _mapping(sections.get(section))) + + +def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> dict[str, object]: + """One block every fragment may declare, peers that say the same thing folded together. + + Equality of the claims rather than "the same or less": between peers + neither declaration is the one being restated, so a field only one of them + writes is a difference nothing settles. + """ + merged: dict[str, object] = {} + for name, sections in read.items(): + for key, block in _mapping(sections.get(section)).items(): + if key in merged and _claims(merged[key]) != _claims(block): + raise LanguageError( + f"fragments '{_author_of(read, section, key)}' and '{name}' say different things about " + f'the {label} {key!r}: {merged[key]!r} against {block!r}. A declaration two fragments ' + f'share is one both say the same thing about: make the two identical, or give one of ' + f'them a name of its own.' + ) + merged.setdefault(key, block) + return merged + + +def _claims(block: object) -> object: + """*block* without its prose, which is what the declaration says rather than a remark about it.""" + return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block + + +def _claimed(read: Mapping[str, dict[str, object]], section: str, sums: Mapping[str, object]) -> dict[str, object]: + """One block of owned declarations, a name claimed twice being the refusal. + + The terms of a sum are left to [`_summed_terms`][]. + """ + merged: dict[str, object] = {} + for name, sections in read.items(): + for key, block in _mapping(sections.get(section)).items(): + if key in sums: + continue + if key in merged: + hint = ( + ' If each fragment adds a term to one sum, mark the name `additive: true` on a ' + '`given: expressions:` entry in the file that reads it.' + if section == 'expressions' + else '' + ) + raise LanguageError( + f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " + f'{_singular(section)} {key!r}. Two of the same kind of thing are two rows of a dimension ' + f'rather than two fragments: merge the fragment once, and let the data carry both. ' + f'Different math under one spelling is a rename: call one of them something else.{hint}' + ) + merged[key] = block + return merged + + +def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: + """Every ``given: expressions:`` entry, the ones two fragments share folded together. + + Two readings of one name agree on the frame, compared as a set. Prose is + not a claim, and the flag is said once for every reader, so the first + description and any reader's flag are carried. + """ + agreed: dict[str, dict[str, object]] = {} + for name, given in asked.items(): + for key, block in _mapping(given.get('expressions')).items(): + entry = cast('dict[str, object]', block) + if key not in agreed: + agreed[key] = dict(entry) + continue + held = agreed[key] + if set(cast('list[str]', held['dims'])) != set(cast('list[str]', entry['dims'])): + raise LanguageError( + f"fragments '{_author_of(asked, 'expressions', key)}' and '{name}' say different things " + f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' + f'files read one name over one frame: make the two identical.' + ) + held['additive'] = bool(held.get('additive') or entry.get('additive')) + held['description'] = held.get('description') or entry.get('description') + return agreed + + +def _summed_terms( + read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], sums: Mapping[str, dict[str, object]] +) -> dict[str, object]: + """Every marked sum, its terms summed in the fragments' name order, each in parentheses. + + One term is carried as written. A term written as ``cases:``, a term over a + dimension the marked entry does not state, and a fragment that declares a + term and reads the name without carrying the marked entry are refused. + """ + summed: dict[str, object] = {} + for key, entry in sums.items(): + terms = [ + (name, _as_mapping(_mapping(sections.get('expressions'))[key])) + for name, sections in sorted(read.items()) + if key in _mapping(sections.get('expressions')) + ] + for name, block in terms: + _one_term(key, entry, name, block, loaded[name]) + bodies = [cast('str', block['expression']) for _, block in terms] + if not bodies: + continue + term: dict[str, object] = { + 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) + } + said = next((b['description'] for _, b in terms if b.get('description')), None) + if said is not None and not entry.get('description'): + term['description'] = said + summed[key] = term + return summed + + +def _as_mapping(block: object) -> dict[str, object]: + """A named expression as ``to_dict`` wrote it, the one-line form read as its mapping.""" + return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} + + +def _one_term(key: str, entry: Mapping[str, object], name: str, block: Mapping[str, object], spec: Spec) -> None: + """Refuse a term of the sum *key* that cannot be summed as written, or that its own file misreads.""" + if block.get('cases'): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} written as `cases:`. The terms of a sum are summed as " + f'written, and a set of cases is no one body: name the cased term as its own expression in ' + f"'{name}', and write that name as the term." + ) + stated = cast('list[str]', entry['dims']) + frame = spec.program.expressions[key].dims + if extra := sorted(set(frame) - set(stated)): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} over {sorted(frame)}, where the sum is over " + f'{sorted(stated)}. A term carries no dimension its sum does not state: add {extra} to the ' + f'dims of the marked `given:` entry, or leave them out of the term.' + ) + marks = key in spec.given.expressions and spec.given.expressions[key].additive + if not marks and _reads(spec, key): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} and reads it. On its own the fragment reads its term, " + f'and composed it would read the sum of every term: mark {key!r} `additive: true` under ' + f"'given: expressions:' in '{name}' to read the sum, or read it in another file." + ) + + +def _reads(spec: Spec, key: str) -> bool: + """Whether the math or another named expression of *spec* names *key*.""" + program = spec.program + others = [entry.expression for name, entry in program.expressions.items() if name != key] + return any(isinstance(node, Named) and node.name == key for node in walk(*program.roots, *others)) + + +def _folded( + read: Mapping[str, dict[str, object]], + merged: Mapping[str, object], + loaded: Mapping[str, Spec], + readings: Mapping[str, dict[str, object]], +) -> dict[str, object]: + """The ``given:`` block the composition still carries, once every reading a sibling introduces is spent. + + A given declaration is what a fragment expects of a name a sibling owns. + Where the sibling is in the composition the expectation is checked and + then dropped, so the composed spec declares the name once. A given + expression is checked against the frame of the definition's body: the body + carries no dimension the reader does not state. A marked entry is kept + beside the sum, so the composed spec still says the name is one. + """ + asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} + left: dict[str, object] = {} + for kind, label in GIVEN_KINDS.items(): + introduced = _mapping(merged.get(kind)) + agreed = readings if kind == 'expressions' else _agreed(asked, kind, label) + for key, block in agreed.items(): + _same_kind(asked, read, merged, kind, key) + if kind == 'expressions' and _mapping(block).get('additive'): + continue + if key in introduced and kind == 'expressions': + _within_frame(asked, read, key, block, _definer_frame(loaded, key)) + elif key in introduced and not _says_less(block, introduced[key]): + raise LanguageError( + f"fragment '{_author_of(asked, kind, key)}' reads the {label} {key!r} as {block!r}, where " + f"'{_author_of(read, kind, key)}' introduces it as {introduced[key]!r}. A given declaration " + f'says the same as the declaration it is folded into, or less: restate the frame as the ' + f'introducer declares it, or leave the field out.' + ) + kept = { + key: block + for key, block in agreed.items() + if key not in introduced or (kind == 'expressions' and _mapping(block).get('additive')) + } + if kept: + left[kind] = kept + return left + + +def _definer_frame(loaded: Mapping[str, Spec], key: str) -> tuple[str, ...]: + """The frame of the one definition of *key*, as the fragment that writes it reads it.""" + return next(spec.program.expressions[key].dims for spec in loaded.values() if key in spec.program.expressions) + + +#: The given kinds whose names share the flat namespace an expression reads. +#: A row family is named only in ``dual()``, apart from it. +READ_KINDS = ('parameters', 'variables', 'expressions') + + +def _same_kind( + asked: Mapping[str, dict[str, object]], + read: Mapping[str, dict[str, object]], + merged: Mapping[str, object], + kind: str, + key: str, +) -> None: + """Refuse a given declaration whose name a sibling introduces as another kind of thing.""" + if kind not in READ_KINDS: + return + for other in READ_KINDS: + if other != kind and key in _mapping(merged.get(other)): + raise LanguageError( + f"fragment '{_author_of(asked, kind, key)}' reads {key!r} as a {GIVEN_KINDS[kind]}, where " + f"'{_author_of(read, other, key)}' introduces it under '{other}:'. A given declaration reads a " + f"name as the kind of thing its introducer declares: move it under 'given: {other}:'." + ) + + +def _within_frame( + asked: Mapping[str, dict[str, object]], + read: Mapping[str, dict[str, object]], + key: str, + block: object, + frame: tuple[str, ...], +) -> None: + """Refuse a definition whose body carries a dimension the given expression's reader does not state. + + The reader's ``dims`` bounds what it reads. A body over fewer dimensions is + left to the composed spec's load, which refuses a row it would repeat and + accepts one another term carries the dimension through. + """ + stated = cast('list[str]', _mapping(block)['dims']) + if extra := sorted(set(frame) - set(stated)): + raise LanguageError( + f"fragment '{_author_of(asked, 'expressions', key)}' reads the given expression {key!r} over " + f"{sorted(stated)}, where '{_author_of(read, 'expressions', key)}' defines it over {sorted(frame)}. " + f'A given expression is read over at most the frame its reader states, and this body carries ' + f'{extra} beyond it: add {extra} to the dims of the given entry.' + ) + + +def _says_less(reader: object, introducer: object) -> bool: + """Whether every claim *reader* makes is one *introducer* makes too. + + Both come from a loaded spec's ``to_dict``, which writes every default + out, so a field one of them left to its default is still a claim here. + """ + return all(_mapping(introducer).get(key) == value for key, value in _mapping(_claims(reader)).items()) + + +def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object] | None: + """Every fragment's objective summed, each term in parentheses, or ``None`` where none declares one. + + The terms are summed in the fragments' name order, so the order they were + passed in does not reach the expression. The first description in that + order is carried, as a shared dimension's is. The senses have to agree: a sum has + one sense, and negating the odd one out would be this function deciding what + a spec means. + """ + declared = {name: _mapping(sections['objective']) for name, sections in read.items() if sections.get('objective')} + if not declared: + return None + senses = {name: objective.get('sense', 'minimize') for name, objective in declared.items()} + if len(set(senses.values())) > 1: + spelled = ', '.join(f"'{name}' {sense}s" for name, sense in sorted(senses.items())) + raise LanguageError( + f'the fragments disagree about which way the objective runs: {spelled}. A composed spec has ' + f'one objective and one sense, so write every fragment against the same one: negate the terms ' + f'of the odd one out rather than its sense.' + ) + ordered = [objective for _, objective in sorted(declared.items())] + terms = [objective['expression'] for objective in ordered] + joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) + summed: dict[str, object] = {'sense': next(iter(senses.values())), 'expression': joined} + if description := next((o['description'] for o in ordered if o.get('description')), None): + summed['description'] = description + return summed + + +def override( + base: str | Path | Mapping[str, object] | Spec, + patches: Mapping[str, str | Path | Mapping[str, object] | Spec], +) -> Spec: + """*base* with each patch laid over it, and nothing laid over another patch. + + Args: + base: The spec being extended: a YAML path, YAML text, a mapping, or a + loaded [`Spec`][mathspec.spec.Spec]. + patches: What each patch is called, to the patch. The name is what an + error calls it. The patches must write disjoint fields, so the + order they are given in cannot change the result. + + Returns: + The patched spec, loaded. + + Raises: + LanguageError: The base does not load; the patched spec does not + load; a patch edits or removes a declaration its base does not + declare; a patch creates one that is not whole; a patch redeclares + or removes a dimension or a relation; a patch sets a whole section + to ``null``; or two patches write one field. + FileNotFoundError: A ``str`` with no newline that names no file. + """ + read = {name: _declarations(patch) for name, patch in patches.items()} + _disjoint(read) + + result = to_spec(base).to_dict() + for name, patch in read.items(): + result = _lay_over(result, deepcopy(patch), name) + return to_spec(result) + + +def _declarations(source: str | Path | Mapping[str, object] | Spec) -> dict[str, object]: + """A patch as the mapping it declares, whatever shape it arrived in. + + Deliberately not [`to_spec`][mathspec.validation.to_spec]: a patch carrying a + ``null`` or naming only the field it changes is not a spec. + """ + if isinstance(source, Spec): + return source.to_dict() + if isinstance(source, Mapping): + return dict(source) + return read_spec(source) + + +def _mapping(value: object) -> dict[str, object]: + """*value* as the mapping a section or a declaration is, an absent one read as empty. + + A file is read before it is validated, so nothing here has checked the + shape; the closed schema refuses any other shape when the result loads. + """ + return cast('dict[str, object]', value or {}) + + +def _singular(section: str) -> str: + """What one entry in *section* is called, ``sos`` and ``piecewise`` not being plurals.""" + return IRREGULAR.get(section, section[:-1]) + + +def _entry_class(owner: type[BaseModel], field: str) -> type[BaseModel]: + """The schema's own class for one entry under *field* of *owner*. + + Read off the annotation rather than listed here, so a section added to the + schema cannot be laid over by a rule that does not know what it is made of. + """ + annotation = owner.model_fields[field].annotation + inner = [arg for arg in get_args(annotation) if arg is not type(None)] + return cast('type[BaseModel]', inner[-1] if inner else annotation) + + +def _whole(cls: type[BaseModel], block: object) -> bool: + """Whether *block* is a declaration on its own, which is what lets a patch create one.""" + try: + cls.model_validate(block) + except ValidationError: + return False + return True + + +def _incomplete(label: str, cls: type[BaseModel], block: object) -> str: + """What *block* is short of, in the schema's own words rather than a second list.""" + fields = cls.model_fields + missing = sorted(name for name, field in fields.items() if field.is_required() and name not in _mapping(block)) + if missing: + return f'{_a(label)} needs {_and_list(missing)}' + try: + cls.model_validate(block) + except ValidationError as e: + return str(schema_error(e)) + raise AssertionError(f'{_a(label)} asked what it is short of is whole: {block!r}') + + +def _a(noun: str) -> str: + """*noun* under the article that reads: an objective, a constraint.""" + return f'an {noun}' if noun[0] in 'aeiou' else f'a {noun}' + + +def _and_list(names: Iterable[str]) -> str: + """``a``, ``a and b``, ``a, b and c``: the field names a message ends on.""" + spelled = [f'`{name}`' for name in names] + if len(spelled) == 1: + return spelled[0] + return f'{", ".join(spelled[:-1])} and {spelled[-1]}' + + +def _writes(patch: Mapping[str, object]) -> list[tuple[str, ...]]: + """Every field *patch* writes, as a path. + + A removal is the declaration's own path, so it overlaps every edit inside + that declaration: removing and editing one declaration is two patches + disagreeing, whichever order they would have been laid in. + """ + paths: list[tuple[str, ...]] = [] + for key, value in patch.items(): + if key in SECTIONS: + for name, block in _mapping(value).items(): + paths.extend(_leaves((key, name), block)) + elif key == 'objective': + paths.extend(_leaves(('objective',), value)) + else: + paths.append((key,)) + return paths + + +def _leaves(prefix: tuple[str, ...], value: object) -> list[tuple[str, ...]]: + """The paths *value* writes under *prefix*, a mapping being walked into and anything else a leaf.""" + if isinstance(value, dict) and value: + return [leaf for key, inner in value.items() for leaf in _leaves((*prefix, key), inner)] + return [prefix] + + +def _disjoint(read: Mapping[str, dict[str, object]]) -> None: + """Refuse two patches that write one field, which is the only way order could matter.""" + claimed: dict[tuple[str, ...], str] = {} + for name, patch in read.items(): + for path in _writes(patch): + for other, owner in claimed.items(): + if path[: len(other)] == other or other[: len(path)] == path: + raise LanguageError(_overlap_message(owner, other, name, path)) + claimed[path] = name + + +def _overlap_message(owner: str, claimed: tuple[str, ...], name: str, path: tuple[str, ...]) -> str: + """The refusal for two patches writing one field, naming both and the rewrite.""" + where = f"'{owner}' writes {'.'.join(claimed)} and '{name}' writes {'.'.join(path)}" + if claimed == path: + where = f'both write {".".join(path)}' + return ( + f"patches '{owner}' and '{name}': {where}. Patches laid on one base are disjoint, so nothing " + f'decides which of two writes wins. Write the change in one patch, or lay one patch on the ' + f"result of the other: override(override(base, {{'{owner}': …}}), {{'{name}': …}})." + ) + + +def _lay_over(base: dict[str, object], patch: dict[str, object], name: str) -> dict[str, object]: + """One patch over one base, a section at a time, the base left as it was.""" + laid = dict(base) + for key, value in patch.items(): + if key == 'given': + laid[key] = _given(_mapping(laid.get(key)), _section(value, key, name), name) + elif key in SHARED_SECTIONS: + laid[key] = _shared(_mapping(laid.get(key)), _section(value, key, name), key, name) + elif key in OWNED_SECTIONS: + block = _section(value, key, name) + laid[key] = _owned(_mapping(laid.get(key)), block, _singular(key), _entry_class(Spec, key), name) + elif key == 'objective': + laid = _objective(laid, value, name) + elif value is None: + laid.pop(key, None) + else: + laid[key] = value + return laid + + +def _section(value: object, where: str, name: str) -> dict[str, object]: + """The block a patch writes under one section, a ``null`` section being refused rather than read as empty. + + A section is not a declaration, so the removal marker does not reach it. An + empty mapping laid over a base says nothing either, and this is the spelling + a writer reaches for when they mean to empty the section. + """ + if value is None: + raise LanguageError( + f"patch '{name}' sets '{where}' to null, which removes nothing: the removal marker names one " + f'declaration, and a section is not one. Remove the declarations one at a time, each under its ' + f'own name, or leave the section out of the patch.' + ) + return cast('dict[str, object]', value) + + +def _given(declared: dict[str, object], patch: dict[str, object], name: str) -> dict[str, object]: + """The ``given:`` block, one kind laid over at a time, so naming the columns keeps the row families. + + A kind the block does not have is carried as written, and the closed + schema refuses it at load. + """ + out = dict(declared) + for kind, block in patch.items(): + if kind in GIVEN_KINDS: + cls = _entry_class(GivenBlock, kind) + entries = _section(block, f'given: {kind}:', name) + out[kind] = _owned(_mapping(out.get(kind)), entries, GIVEN_KINDS[kind], cls, name) + else: + out[kind] = block + return out + + +def _shared(declared: dict[str, object], patch: dict[str, object], section: str, name: str) -> dict[str, object]: + """One ``dimensions`` or ``relations`` block: a patch adds one or restates one, never changes or drops it. + + The restatement is compared for equality rather than field by field: a + patch that names half a declaration is as much a second reading of the + coordinate space as one that names another value. + """ + out = dict(declared) + singular = _singular(section) + for key, block in patch.items(): + if block is None: + raise LanguageError( + f"patch '{name}' removes the {singular} '{key}'. The coordinate space is what the math is " + f'written over, and a patch adjusts the math rather than the space: leave the {singular} out ' + f'of the patch, and remove the declarations written over it one at a time.' + ) + if key not in out: + out[key] = block + elif out[key] != block: + raise LanguageError( + f"patch '{name}' declares the {singular} '{key}' as {block!r}, where its base " + f'declares {out[key]!r}. A patch adjusts the math, not the coordinate space the math is ' + f'already written over: restate the declaration word for word, leave it out, or give the ' + f'patch {_a(singular)} of its own under a name of its own.' + ) + return out + + +def _owned( + declared: dict[str, object], patch: dict[str, object], label: str, cls: type[BaseModel], name: str +) -> dict[str, object]: + """One section of the math, each entry editing what is there or creating what is whole.""" + out = dict(declared) + for key, block in patch.items(): + if block is None: + _removed(out, key, label, name) + elif key in out: + out[key] = _field_by_field(out[key], block) + elif _whole(cls, block): + out[key] = block + else: + raise LanguageError( + f"patch '{name}' edits the {label} '{key}', which its base does not declare. " + f'{did_you_mean(key, list(out))} A patch creates a declaration only by writing it whole, ' + f'and this one is not: {_incomplete(label, cls, block)}.' + ) + return out + + +def _removed(out: dict[str, object], key: str, label: str, name: str) -> None: + """Delete what the patch nulled, refusing a removal its base cannot satisfy.""" + if key not in out: + raise LanguageError( + f"patch '{name}' removes the {label} '{key}', which its base does not declare. " + f'A removal is a claim about what is there, so a stale one is a patch that no longer describes ' + f'the spec it lands on. ' + did_you_mean(key, list(out)) + ) + del out[key] + + +def _objective(laid: dict[str, object], patch: object, name: str) -> dict[str, object]: + """The one declaration that is not keyed by a name, laid over by the same three rules.""" + out = dict(laid) + standing = out.get('objective') + cls = _entry_class(Spec, 'objective') + if patch is None: + if standing is None: + raise LanguageError( + f"patch '{name}' removes the objective, which its base does not declare. A removal is a " + f'claim about what is there, and a spec with no objective is already the feasibility ' + f'problem this patch is asking for.' + ) + del out['objective'] + elif standing is not None: + out['objective'] = _field_by_field(standing, patch) + elif _whole(cls, patch): + out['objective'] = patch + else: + raise LanguageError( + f"patch '{name}' edits the objective, which its base does not declare. A patch creates the " + f'objective only by writing it whole, and this one is not: {_incomplete("objective", cls, patch)}.' + ) + return out + + +def _field_by_field(under: object, over: object) -> object: + """*over* laid on *under*: mappings merge, ``None`` drops the field, and everything else replaces. + + A dropped field takes the schema's default when the result loads, which + is what makes ``upper: null`` an open bound and ``domain: null`` a + continuous variable, whatever the schema lets a file write there. + """ + if isinstance(under, dict) and isinstance(over, dict): + merged = dict(under) + for key, value in over.items(): + if value is None: + merged.pop(key, None) + else: + merged[key] = _field_by_field(merged.get(key), value) + return merged + return over diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index bd958a2d..dd81de51 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -67,13 +67,13 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: return frozenset() if isinstance(node, Parameter): - return frozenset(schema.parameters[node.name].dims) + return frozenset({**schema.parameters, **schema.given.parameters}[node.name].dims) if isinstance(node, Variable): - return frozenset(schema.variables[node.name].dims) + return frozenset({**schema.variables, **schema.given.variables, **schema.given.expressions}[node.name].dims) if isinstance(node, Dual): - return frozenset(schema.constraints[node.constraint].dims) + return frozenset({**schema.constraints, **schema.given.constraints}[node.constraint].dims) if isinstance(node, Named): return _named_dims(node, schema, context) @@ -234,7 +234,7 @@ def _check_named_amount( if not isinstance(amount, str): return words = AMOUNTS[verb] - declared = schema.parameters[amount] + declared = {**schema.parameters, **schema.given.parameters}[amount] if node.along in declared.dims: raise DimensionError( f'{context}: {verb}({kwarg}={amount}) steps along ' @@ -273,7 +273,7 @@ def check_schema(schema: Spec, program: Program) -> None: for side in ('lower', 'upper'): bound = getattr(vdef.bounds, side) if isinstance(bound, str): - bdims = frozenset(schema.parameters[bound].dims) + bdims = frozenset({**schema.parameters, **schema.given.parameters}[bound].dims) if not bdims <= frame: raise DimensionError( f"{context}: bounds.{side} parameter '{bound}' has dims " @@ -292,6 +292,16 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) + for ename, sum_entry in schema.given.expressions.items(): + if not (sum_entry.additive and ename in program.expressions): + continue + if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): + raise DimensionError( + f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " + f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " + f'them out of the term.' + ) + for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" diff --git a/src/mathspec/errors.py b/src/mathspec/errors.py index 023cf34e..4251ace2 100644 --- a/src/mathspec/errors.py +++ b/src/mathspec/errors.py @@ -18,7 +18,7 @@ #: Which pass an [`Advice`][] comes from. Closed, like the operator set: a #: consumer filtering on it can enumerate every value. -AdviceKind = Literal['never-an-axis', 'unbounded'] +AdviceKind = Literal['never-an-axis', 'given', 'unbounded'] @dataclass(frozen=True) diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index b2ce37f4..e057dd2e 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -29,6 +29,8 @@ ConstraintDeclaration, DimensionDeclaration, ExpressionDeclaration, + GivenDeclaration, + GivenTargets, Link, Mask, Named, @@ -50,6 +52,7 @@ resolve_expression_text, resolve_where_text, ) +from mathspec.spec import defined_sums from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: @@ -195,6 +198,7 @@ def lower(schema: Spec) -> Program: assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption + sums = defined_sums(schema) program = Program( parameters={ name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) @@ -218,10 +222,29 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description, + description=schema.expressions[name].description + or (schema.given.expressions[name].description if name in sums else None), + additive=name in sums, ) for name, entry in entries.items() }, + given=GivenTargets( + parameters={ + name: ParameterDeclaration(tuple(g.dims), g.dtype, g.description) + for name, g in schema.given.parameters.items() + }, + variables={ + name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.variables.items() + }, + constraints={ + name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() + }, + expressions={ + name: GivenDeclaration(tuple(g.dims), g.description, additive=g.additive) + for name, g in schema.given.expressions.items() + if name not in sums + }, + ), description=schema.description, ) if errors := emitted_name_errors(schema, program): diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 36483e9f..1f47fd1c 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -58,6 +58,8 @@ 'ExpressionComparison', 'ExpressionDeclaration', 'Footprint', + 'GivenDeclaration', + 'GivenTargets', 'GroupSum', 'Link', 'Mask', @@ -609,6 +611,49 @@ class VariableDeclaration: description: str | None = None +@dataclass(frozen=True) +class GivenDeclaration: + """A column or a row family this program reads and does not build. + + The frame is the whole declaration. A consumer looks the name up in the + model this one is layered onto, checks the frame against what it finds, + and refuses a name the host does not provide. + """ + + dims: tuple[str, ...] + description: str | None = None + #: A given expression marked as a sum other files add terms to. + additive: bool = False + + +@dataclass(frozen=True) +class GivenTargets: + """What a program reads and does not build, by kind. + + Every group is empty in a program built from one whole spec. Every group + is sealed at construction, like every group of [`Program`][]. + """ + + #: Data another file declares, by name, with the dtype a where compares against. + parameters: Mapping[str, ParameterDeclaration] = Sealed({}) + #: Columns the host model provides, by name. + variables: Mapping[str, GivenDeclaration] = Sealed({}) + #: Row families the host model provides, by name, read back after the solve. + constraints: Mapping[str, GivenDeclaration] = Sealed({}) + #: Named expressions the host model defines, by name. An expression reads + #: each as a [`Variable`][] over the frame declared here, since the + #: body is the host's. + expressions: Mapping[str, GivenDeclaration] = Sealed({}) + + def __post_init__(self) -> None: + for f in fields(self): + object.__setattr__(self, f.name, Sealed(getattr(self, f.name))) + + def __bool__(self) -> bool: + """Whether the program reads anything it does not build.""" + return bool(self.parameters or self.variables or self.constraints or self.expressions) + + @dataclass(frozen=True) class ConstraintDeclaration: """``lhs sense rhs`` for each coord combination of ``dims``. @@ -670,6 +715,9 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None + #: A sum other files add terms to: the file marks the name so under + #: ``given: expressions:``, and this body is the sum so far. + additive: bool = False @dataclass(frozen=True) @@ -897,6 +945,10 @@ class Program: #: named expression is outside the language is refused by every verb that #: reads the file rather than only by the one that reads the expression. expressions: Mapping[str, ExpressionDeclaration] = Sealed({}) + #: What this program reads and does not build ([`GivenTargets`][]). A + #: model it is layered onto provides each name; nothing here emits a + #: column or a row. + given: GivenTargets = GivenTargets() #: What the file as a whole is, as its ``description:`` says. description: str | None = None diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 2a83478c..d08f2cb7 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -46,6 +46,7 @@ RelationDeclaration, carries_variable, ) +from mathspec.spec import defined_sums if TYPE_CHECKING: from collections.abc import Iterable @@ -86,17 +87,23 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - self.variables = frozenset(schema.variables) - self.parameters = frozenset(schema.parameters) + defined = defined_sums(schema) + given = {name: g for name, g in schema.given.expressions.items() if name not in defined} + variables = {**schema.variables, **schema.given.variables, **given} + parameters = {**schema.parameters, **schema.given.parameters} + #: Every name an expression reads as a column: the variables, and the + #: given expressions, whose bodies another file holds. + self.variables = frozenset(variables) + self.parameters = frozenset(parameters) self.dimensions = frozenset(schema.dimensions) #: The declared constraint names, off the flat namespace: a bare name #: never reaches them, so a spec may name a constraint after a variable. #: Consulted only in ``dual()``'s argument position. - self.constraints = frozenset(schema.constraints) + self.constraints = frozenset({**schema.constraints, **schema.given.constraints}) #: name -> declared dtype, for dimensions, parameters and relations alike; #: what a where comparison checks its literal against. self.dtypes: dict[str, DeclaredDtype] = { - **{p: pd.dtype for p, pd in schema.parameters.items()}, + **{p: pd.dtype for p, pd in parameters.items()}, **{d: dd.dtype for d, dd in schema.dimensions.items()}, } #: relation name -> its columns and key, as declared. @@ -107,8 +114,8 @@ def __init__(self, schema: Spec) -> None: #: parameters by their ``dims``, variables by their frame. Stamped onto #: each leaf a where names, the way a relation leaf carries ``over``. self.leaf_dims: dict[str, tuple[str, ...]] = { - **{p: tuple(pd.dims) for p, pd in schema.parameters.items()}, - **{v: tuple(vd.dims) for v, vd in schema.variables.items()}, + **{p: tuple(pd.dims) for p, pd in parameters.items()}, + **{v: tuple(vd.dims) for v, vd in variables.items()}, } #: named expression -> its resolved node, or ``None``, and its refusals; #: filled the first time anything reads the name. @@ -237,7 +244,8 @@ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] return ( f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n" f' Constraints: {sorted(self.constraints)}\n' - f"Check for typos, or declare '{name}' under 'constraints:'." + f"Check for typos, or declare '{name}': under 'constraints:' if this file builds the row, " + f"or under 'given: constraints:' if it reads the dual of a row another model builds." ) diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 37b9a1b4..bed29f08 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -283,6 +283,90 @@ def _absence_needs_a_mask(self) -> VariableBlock: return self +class GivenParameterBlock(_StrictBlock): + """Data this file reads and another file declares. + + It says what a [`ParameterBlock`][] says, because the frame and the + dtype are all a parameter declaration holds: a where compares against the + dtype, and the dim rules read the frame. + """ + + _label: ClassVar[str] = 'a given parameter declaration' + + dims: list[str] + dtype: ParameterDtype = 'float' + description: str | None = None + + +class GivenVariableBlock(_StrictBlock): + """A column this file reads and another file introduces. + + The frame and the domain are all this file states. The file that introduces + the column owns its bounds and its mask. + """ + + _label: ClassVar[str] = 'a given variable declaration' + + dims: list[str] + domain: VariableDomain = 'continuous' + description: str | None = None + + +class GivenConstraintBlock(_StrictBlock): + """A row family this file reads the dual of and another model builds. + + The frame says how many duals there are and what indexes them, which is + what ``dual()`` needs. There is no ``expression:``, because nothing here + builds a row. + """ + + _label: ClassVar[str] = 'a given constraint declaration' + + dims: list[str] + description: str | None = None + + +class GivenExpressionBlock(_StrictBlock): + """A named expression this file reads and another file defines. + + The frame is all this file states. This file reads the name as a quantity + over that frame, affine in the columns, as it reads a given variable: the + body is the definer's, and the composed spec holds the body to the rules + of every place this file reads it. + + ``additive: true`` says the name is a sum other files add terms to. Each + of them declares its term as an ordinary named expression under the name, + and [`merge`][mathspec.composition.merge] sums the terms. A file that marks + the name may declare a term of its own too, and then reads the sum so far. + """ + + _label: ClassVar[str] = 'a given expression declaration' + + dims: list[str] + #: Whether the name is a sum other files add terms to. + additive: bool = False + description: str | None = None + + +class GivenBlock(_StrictBlock): + """What this file reads and does not build, by kind. Closed at the four kinds.""" + + _label: ClassVar[str] = 'a given block' + + #: Data another file declares ([`GivenParameterBlock`][]). + parameters: dict[str, GivenParameterBlock] = {} + #: Columns another file introduces ([`GivenVariableBlock`][]). + variables: dict[str, GivenVariableBlock] = {} + #: Row families another model builds ([`GivenConstraintBlock`][]). + constraints: dict[str, GivenConstraintBlock] = {} + #: Named expressions another file defines ([`GivenExpressionBlock`][]). + expressions: dict[str, GivenExpressionBlock] = {} + + def __bool__(self) -> bool: + """Whether the file reads anything it does not build.""" + return bool(self.parameters or self.variables or self.constraints or self.expressions) + + class ConstraintBlock(_StrictBlock): """A declared constraint: one rule, over one frame.""" @@ -709,7 +793,7 @@ class Spec(_StrictBlock): [`LanguageError`][] on a spec the language refuses. Holding one is the proof, so nothing downstream checks it again. - The API is the eleven declaration sections plus ``version`` and + The API is the twelve declaration sections plus ``version`` and ``description``, three ways back out — [`to_dict`][] for the spec as data, [`to_yaml`][] for the file a reviewer reads, [`expand`][] for the spec with its formulations written out as plain rows — and [`program`][], the @@ -731,6 +815,11 @@ class is pydantic's, not a contract this package keeps. relations: dict[str, RelationBlock] = {} parameters: dict[str, ParameterBlock] = {} variables: dict[str, VariableBlock] = {} + #: What this file reads and does not build ([`GivenBlock`][]): data + #: under ``parameters:``, columns under ``variables:``, row families under + #: ``constraints:`` and definitions under ``expressions:``. Empty in a file + #: that stands alone. + given: GivenBlock = GivenBlock() constraints: dict[str, ConstraintBlock] = {} objective: ObjectiveBlock | None = None expressions: dict[str, ExpressionBlock] = {} @@ -877,13 +966,16 @@ def _names_are_names(self) -> Spec: Read off the spec's own mappings rather than a list of sections, so a section added later cannot be forgotten here — every mapping a Spec - carries is keyed by a declaration name. + carries is keyed by a declaration name. ``given:`` nests its four + mappings one level down, so they are read off [`GivenBlock`][] the + same way. """ + sections = [*self, *((f'given: {kind}', group) for kind, group in self.given)] errors = [ f'{section}: {name!r} is not a name. A declaration is named the way an expression ' f'writes it — a letter or an underscore, then letters, digits or underscores — so ' f'nothing can refer to this one. Rename it.' - for section, value in self + for section, value in sections if isinstance(value, dict) for name in value if not re.fullmatch(NAME, name) @@ -905,6 +997,15 @@ def _lower(self) -> Spec: return self +def defined_sums(schema: Spec) -> frozenset[str]: + """The names *schema* marks ``additive`` under ``given: expressions:`` and declares a term of itself. + + Such a name is defined in the file, and reads as the sum so far: it is not + a name the file reads from elsewhere, so it joins no given group. + """ + return frozenset(name for name, g in schema.given.expressions.items() if g.additive and name in schema.expressions) + + def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in [`FORMULATIONS`][] order — all of them where it names none. diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 05e97fa4..6a85ebd8 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack +from mathspec.errors import SchemaError from mathspec.program import Program from mathspec.typesetting.latex import LatexFormat from mathspec.typesetting.legend import Legend, notice @@ -200,11 +201,27 @@ def typeset_declaration( Raises: ValueError: *fmt* names no format. LanguageError: A spec that does not compile; it does not print. - SchemaError: *name* is declared as none of the five, or as two — a - constraint may share a variable's name; or a symbol table entry + SchemaError: *name* is declared as none of the five, as two — a + constraint may share a variable's name — or under ``given:``, which + prints in the legend rather than as a line; or a symbol table entry names nothing in the spec. """ walk = _walk(spec, fmt, symbols, inline_expressions=inline_expressions) + given = walk.program.given + givens = { + 'parameter': given.parameters, + 'variable': given.variables, + 'expression': given.expressions, + 'constraint': given.constraints, + } + given_kind = next((kind for kind, group in givens.items() if name in group), None) + if given_kind is not None: + msg = ( + f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " + f"this file reads it and does not build it. It prints in the legend, under 'Given', " + f'so call typeset() for the whole spec.' + ) + raise SchemaError(msg) return walk.format.equation(walk.line(name)) diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 0b7989fb..65bac5f4 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -133,7 +133,7 @@ def _op(self, name: OperatorName) -> str: return self.format.operators[name] def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str, list[Entry]]]: - """The sets, parameters, variables and definitions, each with its symbol, its dims and its description. + """The sets, parameters, variables, given declarations and definitions, each with its symbol, its dims and its description. *defined* names the expressions that print under their own symbol, so a legend row stands exactly where a symbol does. @@ -155,13 +155,55 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) for v, block in program.variables.items() ] + given = [ + *( + self._entry( + self.symbols.name[g], + f'{fmt.mono(g)}{self._over(list(block.dims))}, data another file declares', + block.description, + ) + for g, block in program.given.parameters.items() + ), + *( + self._entry(self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}', block.description) + for g, block in program.given.variables.items() + ), + *( + self._entry( + self.symbols.name[g], + f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + + ('a sum other files add terms to' if block.additive else 'an expression another file defines'), + block.description, + ) + for g, block in program.given.expressions.items() + ), + *( + self._entry( + self.symbols.constraint[g], + f'{fmt.mono(g)}{self._over(list(block.dims))}, a row family this file reads the dual of', + block.description, + ) + for g, block in program.given.constraints.items() + ), + ] shown = set(defined) definitions = [ - self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) + self._entry( + self.symbols.name[e], + f'{fmt.mono(e)}{self._over(list(block.dims))}' + + (', a sum other files add terms to' if block.additive else ''), + block.description, + ) for e, block in program.expressions.items() if e in shown ] - groups = (('Sets', sets), ('Parameters', parameters), ('Variables', variables), ('Definitions', definitions)) + groups = ( + ('Sets', sets), + ('Parameters', parameters), + ('Variables', variables), + ('Given', given), + ('Definitions', definitions), + ) return [(title, entries) for title, entries in groups if entries] def _entry(self, symbol: str, what: str, description: str | None) -> Entry: diff --git a/src/mathspec/typesetting/symbols.py b/src/mathspec/typesetting/symbols.py index a4c55d96..da15d756 100644 --- a/src/mathspec/typesetting/symbols.py +++ b/src/mathspec/typesetting/symbols.py @@ -45,22 +45,22 @@ ) # fmt: skip -def _word(name: str, fmt: Format, *, given: bool) -> str: - r"""One name as one symbol: upright where *given*, italic where chosen. +def _word(name: str, fmt: Format, *, upright: bool) -> str: + r"""One name as one symbol: *upright* where the data supplies it, italic where chosen. A Greek name is set as the letter only where chosen. Upright lower-case Greek needs ``upgreek``, which the two-package preamble and GitHub's - MathJax both lack, so a given ``eta`` prints as ``\mathrm{eta}``; a table + MathJax both lack, so an upright ``eta`` prints as ``\mathrm{eta}``; a table entry is how an author who loads ``upgreek`` writes ``\upeta``. """ - if given: + if upright: return fmt.upright(name) if name in _GREEK: return fmt.greek(name) return name if len(name) == 1 else fmt.italic(name) -def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, given: bool = False) -> str: +def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, upright: bool = False) -> str: r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``. An underscore is a qualifier, landing in the superscript, only where its @@ -70,8 +70,8 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv """ head, _, tail = name.partition('_') if tail and (len(head) == 1 or head in _GREEK or head in declared): - return fmt.superscript(_word(head, fmt, given=given), fmt.upright(tail.replace('_', ','))) - return _word(name, fmt, given=given) + return fmt.superscript(_word(head, fmt, upright=upright), fmt.upright(tail.replace('_', ','))) + return _word(name, fmt, upright=upright) def chosen_expressions(program: Program) -> frozenset[str]: @@ -133,18 +133,30 @@ def symbols_for(program: Program, fmt: Format, table: SymbolTable) -> Symbols: f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - chosen = frozenset(program.variables) | chosen_expressions(program) - names = (*program.parameters, *program.variables, *program.expressions) + chosen = ( + frozenset(program.variables) + | frozenset(program.given.variables) + | frozenset(program.given.expressions) + | chosen_expressions(program) + ) + names = ( + *program.parameters, + *program.given.parameters, + *program.variables, + *program.given.variables, + *program.expressions, + *program.given.expressions, + ) declared = frozenset(names) name = { - n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=n not in chosen) + n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, upright=n not in chosen) for n in names } spoken_for = {s for s in name.values() if len(s) == 1} constraint = { - n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=True) - for n in program.constraints + n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, upright=True) + for n in (*program.constraints, *program.given.constraints) } index: dict[str, str] = {} @@ -272,7 +284,16 @@ def checked_against(self, program: Program) -> SymbolTable: def _declared(program: Program) -> set[str]: """Every name *program* declares that a table entry may spell.""" - return set(program.parameters) | set(program.variables) | set(program.expressions) | set(program.constraints) + return ( + set(program.parameters) + | set(program.given.parameters) + | set(program.variables) + | set(program.given.variables) + | set(program.expressions) + | set(program.given.expressions) + | set(program.constraints) + | set(program.given.constraints) + ) def _emitted(program: Program) -> set[str]: diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 271f0dd1..7f3b4f40 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -234,6 +234,8 @@ def __init__( #: Substitute each plain named expression where it is used, rather than #: printing its symbol there and its definition once. self.inline_expressions = inline_expressions + #: Every parameter the math reads, declared here or given. + self._parameters = {**program.parameters, **program.given.parameters} def _frame_of(self, name: str) -> list[str]: """The dims named expression *name* is read over, as its declaration carries them.""" @@ -326,10 +328,11 @@ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]: return self._number(node.value), _ATOM if node.value >= 0 else 1 if isinstance(node, Parameter): - return ctx.indexed(self.symbols.name[node.name], list(self.program.parameters[node.name].dims)), _ATOM + return ctx.indexed(self.symbols.name[node.name], list(self._parameters[node.name].dims)), _ATOM if isinstance(node, Variable): - return ctx.indexed(self.symbols.name[node.name], list(self.program.variables[node.name].dims)), _ATOM + frames = {**self.program.variables, **self.program.given.variables, **self.program.given.expressions} + return ctx.indexed(self.symbols.name[node.name], list(frames[node.name].dims)), _ATOM if isinstance(node, Negate): text, precedence = self._arithmetic(node.operand, ctx) @@ -364,7 +367,8 @@ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]: def _dual(self, node: Dual, ctx: _Context) -> str: """λ subscripted by the constraint's symbol, then the indices of the constraint's own frame.""" - frame = self._sorted(frozenset(self.program.constraints[node.constraint].dims)) + frames = {**self.program.constraints, **self.program.given.constraints} + frame = self._sorted(frozenset(frames[node.constraint].dims)) return self.format.subscript( self._op('dual'), [self.symbols.constraint[node.constraint], *(ctx.subscript(d) for d in frame)] ) @@ -528,7 +532,7 @@ def _where(self, node: Predicate, ctx: _Context) -> tuple[str, int]: if isinstance(node, ParameterDefined): indexed = ctx.indexed(self.symbols.name[node.name], list(node.dims)) - if self.program.parameters[node.name].dtype == 'bool': + if self._parameters[node.name].dtype == 'bool': return indexed, _ATOM return f'{indexed} {self.format.prose(" is defined")}', comparison @@ -932,7 +936,7 @@ def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: def _bound(self, ctx: _Context, value: Expression) -> str: """A bound as the file wrote it: a number, or a parameter indexed over its dims.""" if isinstance(value, Parameter): - return ctx.indexed(self.symbols.name[value.name], list(self.program.parameters[value.name].dims)) + return ctx.indexed(self.symbols.name[value.name], list(self._parameters[value.name].dims)) assert isinstance(value, Constant), 'a bound is a number or the name of a parameter' return self._number(value.value) diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 592894a1..1553e65e 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -28,7 +28,7 @@ from mathspec.piecewise import Emitted as EmittedCurve from mathspec.sos import Emitted as EmittedSet from mathspec.sos import coefficients -from mathspec.spec import NUMERIC_DTYPES, Spec, side_columns +from mathspec.spec import NUMERIC_DTYPES, Spec, defined_sums, side_columns if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -88,6 +88,8 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_shapes(schema), *_sos_bounds(schema), *_piecewise_references(schema), + *_given_constraint_collisions(schema), + *_cased_terms(schema), ] @@ -102,8 +104,11 @@ def _flat_namespace(schema: Spec) -> list[tuple[str, Iterable[str]]]: ('dimension', schema.dimensions), ('relation', schema.relations), ('parameter', schema.parameters), + ('given parameter', schema.given.parameters), ('variable', schema.variables), + ('given variable', schema.given.variables), ('named expression', schema.expressions), + ('given expression', {n: g for n, g in schema.given.expressions.items() if n not in defined_sums(schema)}), ('macro', schema.macros), ] @@ -128,11 +133,40 @@ def _name_collisions(schema: Spec) -> Iterator[str]: seen[name] = kind +def _given_constraint_collisions(schema: Spec) -> Iterator[str]: + """A row family is either built here or given, never both. + + Constraint names sit outside the flat namespace [`_name_collisions`][] + walks, so this is the one place the two constraint sections meet. + """ + for name in schema.given.constraints: + if name in schema.constraints: + yield ( + f"Given constraint '{name}' is also declared under 'constraints:'. A row family is " + f'either built by this file or given to it — drop one of the two.' + ) + + +def _cased_terms(schema: Spec) -> Iterator[str]: + """A term of a sum is one expression, since the terms are summed as written.""" + for name in sorted(defined_sums(schema)): + if schema.expressions[name].cases: + yield ( + f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " + f'The terms are summed as written: name the cased term as its own expression, and write ' + f'that name as the term.' + ) + + def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ *(('Parameter', name, p.dims) for name, p in schema.parameters.items()), *(('Variable', name, v.dims) for name, v in schema.variables.items()), + *(('Given parameter', name, g.dims) for name, g in schema.given.parameters.items()), + *(('Given variable', name, g.dims) for name, g in schema.given.variables.items()), + *(('Given expression', name, g.dims) for name, g in schema.given.expressions.items()), + *(('Given constraint', name, g.dims) for name, g in schema.given.constraints.items()), *(('Constraint', name, c.dims) for name, c in schema.constraints.items()), *(('Named expression', name, e.dims or []) for name, e in schema.expressions.items()), ] @@ -192,14 +226,15 @@ def _relation_targets(schema: Spec) -> Iterator[str]: def _bound_names(schema: Spec) -> Iterator[str]: - """A named bound is a numeric parameter.""" + """A named bound is a numeric parameter, declared here or given.""" + parameters = {**schema.parameters, **schema.given.parameters} for vname, vdef in schema.variables.items(): for side in ('lower', 'upper'): val = getattr(vdef.bounds, side) if not isinstance(val, str): continue - if val in schema.parameters: - dtype = schema.parameters[val].dtype + if val in parameters: + dtype = parameters[val].dtype if dtype not in NUMERIC_DTYPES: yield ( f"Variable '{vname}' bounds.{side}: '{val}' is a {dtype} parameter, and a bound " @@ -322,7 +357,7 @@ def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterabl An emitted variable joins the flat namespace, so any declaration there takes its name; a constraint, a set and an assumption each have their own. """ - sections = {'named expression': 'expressions', 'sos': 'sos'} + sections = {'named expression': 'expressions', 'sos': 'sos', 'given variable': 'given: variables'} declared: dict[str, dict[str, str]] = { 'variable': {name: kind for kind, group in _flat_namespace(schema) for name in group}, 'constraint': dict.fromkeys(schema.constraints, 'constraint'), diff --git a/tests/fixtures.py b/tests/fixtures.py index f44a50d7..9a510b21 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -28,7 +28,7 @@ OPERATOR_PROBES = sorted((EXAMPLES / 'operators').glob('*.yaml')) #: The shape of ``examples/dispatch.yaml`` as a dict a test can vary with -#: :func:`override`: no ``where:``, the constraint named ``balance``, and short +#: :func:`varied`: no ``where:``, the constraint named ``balance``, and short #: names, so a test that prints it asserts on the math rather than on the #: example's own vocabulary. DISPATCH_MODEL: dict[str, Any] = { @@ -60,10 +60,10 @@ } -def override(base: dict[str, Any], **patch: Any) -> dict[str, Any]: +def varied(base: dict[str, Any], **patch: Any) -> dict[str, Any]: """A deep copy of ``base`` with dotted paths replaced, missing parents created. - ``override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})``. + ``varied(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})``. """ raw = copy.deepcopy(base) for dotted, value in patch.items(): @@ -76,12 +76,12 @@ def override(base: dict[str, Any], **patch: Any) -> dict[str, Any]: def schema_of(source: str | Path | dict[str, Any], **patch: Any) -> Spec: - """A ``Spec`` from a YAML path, YAML text, or a raw dict, ``**patch`` applied by :func:`override`. + """A ``Spec`` from a YAML path, YAML text, or a raw dict, ``**patch`` applied by :func:`varied`. ``Path`` means a file, ``str`` means the YAML itself. """ raw = raw_of(source) - return to_spec(override(raw, **patch) if patch else raw) + return to_spec(varied(raw, **patch) if patch else raw) def expanded(source: str | Path | dict[str, Any] | Spec, *kinds: Any, **patch: Any) -> Spec: diff --git a/tests/test_additive.py b/tests/test_additive.py new file mode 100644 index 00000000..91c67545 --- /dev/null +++ b/tests/test_additive.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""A named expression several fragments add terms to. + +A balance reads what every component puts into a bus, and a component file +says what it puts there. The file that reads the total marks it +`additive: true` on its `given:` entry, and states its frame once. Each +component declares its term as an ordinary named expression under that name, +and `merge` sums the terms. The marked entry stays in the composed spec, so a +later merge adds more. +""" + +from __future__ import annotations + +import pytest + +from mathspec import FORMATS, LanguageError, advice, merge, override, to_markdown, to_spec, typeset + +DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} +INJECTION = 'what the components put into a bus' + +#: The balance: it says `injection` is a sum over its frame, and adds nothing to it. +BALANCE = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True, 'description': INJECTION}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, +} + +#: A generator fleet: its term is an ordinary named expression. +FLEET = { + 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, + 'expressions': {'injection': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + +#: A demand, subtracting what it draws. +DEMAND = { + 'dimensions': DIMS, + 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, + 'expressions': {'injection': '-load'}, +} + +#: A store, added in a second merge. +STORAGE = { + 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, + 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, + 'expressions': {'injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, +} + +#: A reader that states the frame and does not say the name is a sum. +CAPPED = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +} + + +# --------------------------------------------------------------------------- +# one file +# --------------------------------------------------------------------------- + + +def test_a_reader_marks_the_sum_and_loads_on_its_own(): + program = to_spec(BALANCE).program + assert program.given.expressions['injection'].additive + assert program.given.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_term_is_an_ordinary_named_expression(): + program = to_spec(FLEET).program + assert not program.expressions['injection'].additive, 'nothing in a plain contributor says it is a term' + + +#: A file that adds a term of its own and reads the sum: it carries both. +HUB = { + **FLEET, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +} + + +def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): + """The marked entry says the file reads the sum so far, which alone is its own term.""" + program = to_spec(HUB).program + assert program.expressions['injection'].additive, 'the entry folds into the definition' + assert not program.given, 'a name this file defines is not one it reads from elsewhere' + + +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): + narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} + narrow = { + **narrow, + 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, + } + with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): + to_spec(narrow) + + +def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): + cased = { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): + to_spec(cased) + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +def test_merging_sums_every_term_of_a_marked_name_in_fragment_name_order(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.expressions['injection'].expression == ( + '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert composed.given.expressions['injection'].additive, 'the marked entry is kept' + assert not composed.program.given, 'and it folds into the definition, so the composed spec reads nothing' + assert composed.program.expressions['injection'].additive + + +def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): + one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + assert one == other + + +def test_a_composed_model_takes_more_terms_in_a_second_merge(): + """The kept entry is what lets a framework ship a composed spec that a project adds to.""" + shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + extended = merge({'shipped': shipped, 'storage': STORAGE}) + assert 'store_p' in extended.expressions['injection'].expression + assert 'gen_p' in extended.expressions['injection'].expression + + +def test_the_sum_takes_the_readers_description(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == INJECTION + + +def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): + with pytest.raises(LanguageError) as raised: + merge({'fleet': FLEET, 'demand': DEMAND}) + message = str(raised.value) + assert "both declare the expression 'injection'" in message + assert '`additive: true` on a `given: expressions:` entry' in message, 'the collision says how to make it a sum' + + +def test_one_term_and_an_unmarked_reader_is_an_ordinary_fold(): + composed = merge({'fleet': FLEET, 'capped': CAPPED}) + assert not composed.given + assert not composed.program.expressions['injection'].additive + + +def test_one_reader_marks_the_sum_and_another_states_the_frame(): + composed = merge({'balance': BALANCE, 'capped': CAPPED, 'demand': DEMAND, 'fleet': FLEET}) + assert composed.given.expressions['injection'].additive, 'the flag comes from either entry' + assert sorted(composed.constraints) == ['balance', 'capped'] + + +def test_two_readers_that_disagree_about_the_frame_are_refused(): + narrow = {**CAPPED, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**narrow, 'constraints': {'capped': {'dims': ['bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): + merge({'balance': BALANCE, 'capped': narrow, 'fleet': FLEET}) + + +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_by_merge(): + narrow = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, + 'variables': {'slack': {'dims': ['bus']}}, + 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection + slack == 0'}}, + } + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' over \['bus', 'snapshot'\]"): + merge({'balance': narrow, 'demand': {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}}, 'fleet': FLEET}) + + +@pytest.mark.parametrize( + 'others', + [ + pytest.param({'demand': DEMAND}, id='beside-another-term'), + pytest.param({}, id='as-the-only-term'), + ], +) +def test_a_contributor_that_reads_the_sum_without_the_entry_is_refused(others): + """Alone it reads its own term; composed it would read the sum, so the file would mean two things.""" + reads_itself = {**FLEET, 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' and reads it"): + merge({'balance': BALANCE, 'fleet': reads_itself, **others}) + + +def test_a_cased_contributor_is_refused_by_merge(): + cased = { + **DEMAND, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + assert to_spec(cased), 'nothing in the file alone says it is a term' + with pytest.raises(LanguageError, match=r"'demand' adds a term to 'injection' written as `cases:`"): + merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) + + +def test_a_marked_sum_nothing_adds_to_stays_under_given(): + """Zero would leave the balance a row with no variable, which the language refuses.""" + composed = merge({'balance': BALANCE, 'other': {'dimensions': DIMS}}) + assert composed.program.given.expressions['injection'].additive + + +def test_a_patch_replaces_a_term_rather_than_adding_one(): + """`override` edits what is there; a new term is a new fragment for `merge`.""" + laid = override(DEMAND, {'double': {'expressions': {'injection': '-2 * load'}}}) + assert laid.expressions['injection'].expression == '-2 * load' + + +# --------------------------------------------------------------------------- +# printing +# --------------------------------------------------------------------------- + + +def test_the_reader_s_legend_says_other_files_add_to_it(): + given = to_markdown(BALANCE).split('#### Given')[1] + assert 'a sum other files add terms to' in given + + +def test_the_composed_legend_says_other_files_add_to_it(): + definitions = to_markdown(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE})).split('#### Definitions')[ + 1 + ] + assert 'a sum other files add terms to' in definitions + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_reader_and_a_composition_print_in_every_format(fmt): + assert typeset(BALANCE, fmt), f'{fmt} rendered nothing for the reader' + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition' + + +def test_the_advice_says_the_files_that_add_terms_complete_the_sum(): + """A marked sum is completed by `merge`, not by a host model, so its note says so.""" + (note,) = [note for note in advice(BALANCE) if note.kind == 'given'] + assert note.subject == 'injection' + assert 'a sum other files add terms to' in note.text + assert 'layered onto' not in note.text, 'no host model is asked for' diff --git a/tests/test_advice.py b/tests/test_advice.py index 407f4ab7..679d491f 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -18,24 +18,24 @@ import pytest from mathspec import AdviceKind, advice, to_spec -from tests.fixtures import SMALL_MODEL, override, raw_of +from tests.fixtures import SMALL_MODEL, raw_of, varied EXAMPLES = Path(__file__).resolve().parents[1] / 'examples' #: ``h`` is the target of ``lk`` and nothing else reaches it; ``g`` is an axis. -TARGET_ONLY = override( +TARGET_ONLY = varied( SMALL_MODEL, variables={'p': {'dims': ['g']}}, objective={'sense': 'minimize', 'expression': 'sum(p * c)'}, ) #: The same with the relation gone, so nothing reaches ``h`` at all. -UNREACHED = override(TARGET_ONLY, relations={}) +UNREACHED = varied(TARGET_ONLY, relations={}) #: A curve on ``p``, so the program of the file as written carries a block and #: the program of its expansion carries the rows. The objective drives ``p`` #: down unopposed by anything but the curve. -CURVED = override( +CURVED = varied( UNREACHED, objective={'sense': 'minimize', 'expression': 'sum(p)'}, dimensions={'g': {'dtype': 'str'}, 'h': {'dtype': 'str'}, 'bp': {'dtype': 'int'}}, @@ -63,14 +63,14 @@ def test_a_dimension_nothing_reaches_is_named(): ], ) def test_a_dimension_something_reaches_is_in_use(patch): - assert not advice(override(TARGET_ONLY, **patch)), ( + assert not advice(varied(TARGET_ONLY, **patch)), ( 'a dimension a relation targets, a declaration indexes or a grouping lands on is in use' ) #: A model with one note of each kind: nothing reaches `h`, and `p` is driven #: down by the objective with an open lower bound and no constraint on it. -BOTH_KINDS = override(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': None}}) +BOTH_KINDS = varied(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': None}}) def test_both_kinds_of_note_come_through_the_one_door(): @@ -78,9 +78,29 @@ def test_both_kinds_of_note_come_through_the_one_door(): assert [(n.kind, n.subject) for n in notes] == [('never-an-axis', 'h'), ('unbounded', 'p')], ( 'the never-an-axis advice comes first, then the unboundedness advice' ) - assert {n.kind for n in notes} == set(get_args(AdviceKind)), ( - 'every kind a consumer can pin against is one this file produces' - ) + + +#: A spec whose only note is the third kind: `flow` is a column this file +#: reads and whatever it is layered onto builds. `p` is bounded on both sides +#: and every dimension is indexed, so neither other pass has anything to say. +READS_A_COLUMN = { + 'dimensions': {'g': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['g']}}}, + 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, + 'constraints': {'tie': {'dims': ['g'], 'expression': 'p == flow'}}, +} + + +def test_a_column_read_and_not_built_is_advised(): + (note,) = advice(READS_A_COLUMN) + assert (note.kind, note.subject) == ('given', 'flow') + assert 'layered onto provides it' in str(note), 'the note says whose job the column is' + assert 'merge()' in str(note), 'and names the verb that folds the reading away where a sibling builds it' + + +def test_every_kind_a_consumer_can_pin_against_is_produced_here(): + kinds = {note.kind for note in (*advice(BOTH_KINDS), *advice(READS_A_COLUMN))} + assert kinds == set(get_args(AdviceKind)), 'every kind a consumer can pin against is one these fixtures produce' def _written(model: dict, tmp_path: Path) -> Path: @@ -136,8 +156,8 @@ def test_every_shipped_formulation_gets_the_answer_its_expansion_gets(example): ignored. With the constraints gone and the cost maximized, the curve is all that holds ``op_cost``. """ - raw = override(raw_of(EXAMPLES / f'{example}.yaml'), constraints={}, **{'objective.sense': 'maximize'}) - assert [(n.kind, n.subject) for n in advice(override(raw, piecewise={}))] == [('unbounded', 'op_cost')], ( + raw = varied(raw_of(EXAMPLES / f'{example}.yaml'), constraints={}, **{'objective.sense': 'maximize'}) + assert [(n.kind, n.subject) for n in advice(varied(raw, piecewise={}))] == [('unbounded', 'op_cost')], ( 'without its curve nothing holds op_cost, so the answer below turns on reading the curve' ) spec = to_spec(raw) diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index a53781e2..053d3d7c 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -14,9 +14,9 @@ from mathspec.boundedness import unbounded_notes from mathspec.operators import BUILTIN_NAMES -from tests.fixtures import SMALL_MODEL, override, schema_of +from tests.fixtures import SMALL_MODEL, schema_of, varied -BASE = override( +BASE = varied( SMALL_MODEL, variables={'v': {'dims': ['g']}, 'w': {'dims': ['g']}}, objective={'sense': 'minimize', 'expression': 'sum(v, over=g)'}, diff --git a/tests/test_canonical.py b/tests/test_canonical.py index fe8476b4..2b645f47 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -33,19 +33,20 @@ parse_expression, ) from mathspec.canonical import _factors, _signed_terms, canonical_text, laid_out, normalised -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, raw_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, raw_of, varied if TYPE_CHECKING: from mathspec._expression_parser import ArithmeticNode, ParsedNode #: Every spec in the repository, the operator probes included. The symbol -#: tables under `examples/symbols/` are not specs and do not load as one. -SPECS = [path for path in sorted(EXAMPLES.rglob('*.yaml')) if 'symbols' not in path.parts] +#: tables under `examples/symbols/` and the patches under a `variants/` folder +#: are not specs and do not load as one. +SPECS = [path for path in sorted(EXAMPLES.rglob('*.yaml')) if not {'symbols', 'variants'} & set(path.parts)] FIXTURE = Path(__file__).resolve().parent / 'fixtures' / 'every_program_node.yaml' def _dumped(**patch: object) -> str: - return ms.to_spec(override(DISPATCH_MODEL, **patch)).to_yaml(canonical=True) + return ms.to_spec(varied(DISPATCH_MODEL, **patch)).to_yaml(canonical=True) def test_two_files_that_mean_the_same_thing_write_the_same_text(): @@ -129,7 +130,7 @@ def test_a_sum_is_broken_one_term_to_a_line(): def test_the_declarations_of_a_section_are_sorted_by_name(): dumped = ms.to_spec( - override(DISPATCH_MODEL, **{'constraints.a_cap.dims': [], 'constraints.a_cap.expression': 'sum(p) >= 0'}) + varied(DISPATCH_MODEL, **{'constraints.a_cap.dims': [], 'constraints.a_cap.expression': 'sum(p) >= 0'}) ).to_yaml(canonical=True) assert dumped.index('a_cap:') < dumped.index('balance:'), 'a section reads in name order, not file order' @@ -144,6 +145,7 @@ def test_the_declarations_of_a_section_are_sorted_by_name(): 'relations', 'parameters', 'variables', + 'given', 'constraints', 'objective', 'expressions', @@ -195,12 +197,12 @@ def test_every_section_is_sorted_by_name(path): def _commitment_with_its_cases_reversed() -> dict[str, object]: raw = raw_of(EXAMPLES / 'commitment.yaml') cases = raw['expressions']['previous_status']['cases'] - return override(raw, **{'expressions.previous_status.cases': dict(reversed(cases.items()))}) + return varied(raw, **{'expressions.previous_status.cases': dict(reversed(cases.items()))}) def _piecewise_with_its_links_reversed() -> dict[str, object]: raw = raw_of(EXAMPLES / 'piecewise.yaml') - return override(raw, **{'piecewise.cost_curve.links': raw['piecewise']['cost_curve']['links'][::-1]}) + return varied(raw, **{'piecewise.cost_curve.links': raw['piecewise']['cost_curve']['links'][::-1]}) @pytest.mark.parametrize( @@ -208,7 +210,7 @@ def _piecewise_with_its_links_reversed() -> dict[str, object]: [ pytest.param( DISPATCH_MODEL, - override(DISPATCH_MODEL, **{'variables.p.dims': ['generator', 'snapshot']}), + varied(DISPATCH_MODEL, **{'variables.p.dims': ['generator', 'snapshot']}), id='a-declarations-dims', ), pytest.param( @@ -222,8 +224,8 @@ def _piecewise_with_its_links_reversed() -> dict[str, object]: id='the-links-of-a-piecewise-block', ), pytest.param( - override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 and cost > 0'}), - override(DISPATCH_MODEL, **{'variables.p.where': 'cost > 0 and p_max > 0'}), + varied(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 and cost > 0'}), + varied(DISPATCH_MODEL, **{'variables.p.where': 'cost > 0 and p_max > 0'}), id='the-predicates-of-a-where', ), ], @@ -492,3 +494,16 @@ def test_a_named_expression_written_on_one_line_is_normalised_too(): frame = {'dimensions': {'t': {'dtype': 'int'}}, 'variables': {'a': {'dims': ['t']}, 'b': {'dims': ['t']}}} one, other = ({**frame, 'expressions': {'total': text}} for text in ('a + b', 'b + a')) assert ms.to_spec(one).to_yaml(canonical=True) == ms.to_spec(other).to_yaml(canonical=True) + + +def test_the_names_a_file_reads_are_sorted_like_the_names_it_declares(): + """`given:` nests its kinds one level below a section, so sorting the sections alone left them in file order.""" + frame = {'dimensions': {'t': {'dtype': 'int'}}, 'constraints': {'c': {'dims': ['t'], 'expression': 'a + b >= 0'}}} + one, other = ( + {**frame, 'given': {'variables': dict(entries)}} + for entries in ( + [('a', {'dims': ['t']}), ('b', {'dims': ['t']})], + [('b', {'dims': ['t']}), ('a', {'dims': ['t']})], + ) + ) + assert ms.to_spec(one).to_yaml(canonical=True) == ms.to_spec(other).to_yaml(canonical=True) diff --git a/tests/test_composition.py b/tests/test_composition.py new file mode 100644 index 00000000..6d80bf46 --- /dev/null +++ b/tests/test_composition.py @@ -0,0 +1,532 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Two verbs, and what each one refuses. + +`merge` composes peers, so a name two fragments declare is a collision and the +order they are given in means nothing. `override` lays a base and its patches, +so a name the patch declares is the point, and what is pinned for it is the +opposite: every collision the caller did not ask for is an error naming both +sides. A patch that lands on nothing, two patches writing one field, a +dimension redeclared or removed under the expressions written over it, and a +whole section set to null are each a spec that would otherwise load and mean +something nobody wrote. +""" + +from __future__ import annotations + +import copy + +import pytest + +import mathspec.spec as spec_module +from mathspec import LanguageError, merge, override, to_markdown, to_spec +from tests.fixtures import DISPATCH_MODEL, varied + +#: The coupling surface a component library agrees on: one flow per port, and +#: one balance per bus. The two fragments below read `flow` under `given:`, so +#: each is a spec on its own as well as a piece of the composition. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'values': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port']}}, + 'constraints': { + 'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus, over=port, into=bus) == 0'} + }, +} + +SUPPLY = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'values': 'port'}}, + 'given': {'variables': {'flow': {'dims': ['snapshot', 'port']}}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': { + 'gen_injects': { + 'dims': ['snapshot', 'generator'], + 'expression': 'at(flow, by=gen_port, over=port, into=generator) == gen_p', + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +DEMAND = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'demand': {'dtype': 'str'}}, + 'relations': {'dem_port': {'key': 'demand', 'values': 'port'}}, + 'given': {'variables': {'flow': {'dims': ['snapshot', 'port']}}}, + 'parameters': {'dem_load': {'dims': ['snapshot', 'demand']}}, + 'constraints': { + 'dem_withdraws': { + 'dims': ['snapshot', 'demand'], + 'expression': 'at(flow, by=dem_port, over=port, into=demand) == -dem_load', + } + }, +} + +LIBRARY = {'surface': SURFACE, 'supply': SUPPLY, 'demand': DEMAND} + + +def test_a_fragment_reads_what_a_sibling_declares(): + """`supply` reads `flow` under `given:`, so it is a spec on its own and a piece of the composition.""" + assert to_markdown(SUPPLY), 'a fragment prints as math on its own' + spec = merge(LIBRARY) + assert sorted(spec.variables) == ['flow', 'gen_p'], "both fragments' columns are in the one spec" + assert not spec.given, 'the reading is spent once the fragment that builds the column is in the composition' + assert to_markdown(spec), 'a composed library prints as math' + + +def test_a_fragment_that_does_not_load_on_its_own_is_refused(): + """A sibling's declarations must not make a broken file load: a fragment is a spec before it is a piece.""" + unread = {key: value for key, value in SUPPLY.items() if key != 'given'} + with pytest.raises(LanguageError, match=r"fragment 'supply' does not load on its own") as raised: + merge({**LIBRARY, 'supply': unread}) + assert "'flow' not found" in str(raised.value), "the fragment's own refusal follows, so the fix is named" + + +def test_the_balance_does_not_grow_when_a_component_type_is_added(): + """What the port convention buys: a component pins its own flow rather than adding a term.""" + three = merge(LIBRARY).constraints['balance'].expression + storage = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'store': {'dtype': 'str'}}, + 'relations': {'st_port': {'key': 'store', 'values': 'port'}}, + 'given': {'variables': {'flow': {'dims': ['snapshot', 'port']}}}, + 'parameters': {'st_capacity': {'dims': ['store']}}, + 'variables': {'st_p': {'dims': ['snapshot', 'store'], 'bounds': {'lower': 0, 'upper': 'st_capacity'}}}, + 'constraints': { + 'st_injects': { + 'dims': ['snapshot', 'store'], + 'expression': 'at(flow, by=st_port, over=port, into=store) == st_p', + } + }, + } + four = merge({**LIBRARY, 'storage': storage}).constraints['balance'].expression + assert three == four, 'the balance is written once, whatever is plugged into it' + + +@pytest.mark.parametrize( + 'fragments', + [ + pytest.param(LIBRARY, id='one-objective'), + pytest.param( + {**LIBRARY, 'demand': {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load)'}}}, + id='an-objective-in-two-fragments', + ), + ], +) +def test_merging_is_order_independent(fragments): + assert merge(fragments) == merge(dict(reversed(list(fragments.items())))) + + +def test_the_fragments_are_never_mutated(): + before = copy.deepcopy(LIBRARY) + merge(LIBRARY) + assert before == LIBRARY, 'a composed spec is a new spec, and the fragments are untouched' + + +@pytest.mark.parametrize( + ('fragments', 'says'), + [ + pytest.param( + { + 'supply': SUPPLY, + 'demand': {**DEMAND, 'parameters': {**DEMAND['parameters'], 'gen_cost': {'dims': ['demand']}}}, + }, + 'two rows of a dimension', + id='one-name-declared-twice', + ), + pytest.param( + { + 'supply': SUPPLY, + 'demand': {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'str'}}}, + }, + 'give one of them a name of its own', + id='one-dimension-described-two-ways', + ), + pytest.param( + {'supply': SUPPLY, 'demand': {**DEMAND, 'objective': {'sense': 'maximize', 'expression': 'sum(dem_load)'}}}, + 'negate the terms', + id='objectives-that-run-opposite-ways', + ), + ], +) +def test_a_disagreement_between_fragments_is_refused(fragments, says): + """No order of the fragments settles any of these, so each is a refusal rather than a rule.""" + with pytest.raises(LanguageError) as raised: + merge(fragments) + message = str(raised.value) + assert says in message, 'the refusal names the rewrite rather than only what is wrong' + assert all(f"'{name}'" in message for name in fragments), 'a disagreement names both fragments' + + +def test_two_descriptions_of_one_dimension_agree_and_the_first_is_carried(): + """Prose is not a claim, so two fragments describing one dimension in their own words agree about it.""" + first = {**SUPPLY, 'dimensions': {**SUPPLY['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'an hour'}}} + second = {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'a step'}}} + composed = merge({'supply': first, 'demand': second}) + assert composed.dimensions['snapshot'].description == 'an hour', ( + "the claim is carried whole, under the first fragment's wording of the prose" + ) + + +def test_the_objectives_are_summed_each_term_parenthesised(): + """`a + b * k` reassociates, so an unparenthesised join composes a different objective.""" + priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} + composed = merge({'surface': SURFACE, 'supply': SUPPLY, 'demand': priced}) + assert composed.objective is not None + assert composed.objective.expression == '(sum(dem_load) * 2) + (sum(gen_p * gen_cost))', ( + "the terms are summed in the fragments' name order, which no argument order can change" + ) + + +def test_a_composed_objective_keeps_the_first_description_a_fragment_gives_it(): + """Prose, as on a shared dimension: the first fragment's wording is carried, and none is lost.""" + said = {**SUPPLY, 'objective': {**SUPPLY['objective'], 'description': 'what running the fleet costs'}} + priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} + composed = merge({'surface': SURFACE, 'supply': said, 'demand': priced}) + assert composed.objective is not None + assert composed.objective.description == 'what running the fleet costs' + + +def test_one_fragment_s_objective_is_carried_as_it_was_written(): + objective = merge(LIBRARY).objective + assert objective is not None + assert objective.expression == SUPPLY['objective']['expression'] + + +def test_fragments_written_against_two_language_versions_are_refused(monkeypatch): + """This reader knows one version, so a second is stood up for the fragments to disagree about.""" + monkeypatch.setattr(spec_module, 'SUPPORTED_VERSIONS', (0, 1)) + with pytest.raises(LanguageError, match=r'One spec has one version') as raised: + merge({'supply': {**SUPPLY, 'version': 0}, 'demand': {**DEMAND, 'version': 1}}) + assert "'supply' says 0" in str(raised.value) and "'demand' says 1" in str(raised.value), 'both are named' + + +def test_the_description_belongs_to_the_composition(): + described = merge({**LIBRARY, 'supply': {**SUPPLY, 'description': 'a fleet'}}, description='a fleet against a load') + assert described.description == 'a fleet against a load' + assert merge({**LIBRARY, 'supply': {**SUPPLY, 'description': 'a fleet'}}).description is None, ( + "no fragment's own description is carried" + ) + + +def test_merge_composes_the_model_and_override_configures_the_run(): + """The two verbs meet by taking and returning what the other does.""" + run = override(merge(LIBRARY), {'project': {'variables': {'gen_p': {'where': 'gen_p_max > 0'}}}}) + assert to_spec(run).variables['gen_p'].where == 'gen_p_max > 0' + + +def test_every_section_a_fragment_owns_reaches_the_composition(): + """`merge` listed its sections by hand, so `assumptions:` fell out of every composed spec.""" + assumed = {**DEMAND, 'assumptions': {'dem_load_positive': 'dem_load >= 0'}} + composed = merge({**LIBRARY, 'demand': assumed}) + assert sorted(composed.assumptions) == ['dem_load_positive'], "the fragment's assumption is in the one spec" + + +def test_a_fragment_is_a_path_as_readily_as_a_mapping(tmp_path): + surface = tmp_path / 'surface.yaml' + surface.write_text(to_spec(SURFACE).to_yaml(), encoding='utf-8') + assert merge({**LIBRARY, 'surface': str(surface)}) == merge(LIBRARY) + + +#: A patch that adds what it needs and a constraint that reads it, so the +#: composed spec is one `to_spec` accepts rather than only one that lays. +CARBON = { + 'parameters': {'co2': {'dims': ['generator']}}, + 'constraints': {'co2_cap': {'dims': [], 'expression': 'sum(p * co2) <= 100'}}, +} + +#: A base that reads a solved spec: one given column, one given constraint and +#: an expression over the constraint, so a patch to `given:` is checked by +#: `to_spec` rather than only laid. +GIVEN_BASE = { + 'dimensions': {'g': {'dtype': 'str'}}, + 'given': {'variables': {'p': {'dims': ['g']}}, 'constraints': {'cap': {'dims': ['g']}}}, + 'expressions': {'price': {'expression': 'dual(cap)'}}, +} + +#: `DISPATCH_MODEL` with no objective, for the patches that ask about one. +FEASIBILITY = {k: v for k, v in DISPATCH_MODEL.items() if k != 'objective'} + + +def test_a_patch_names_only_the_field_it_changes(): + laid = override(DISPATCH_MODEL, {'operate': {'variables': {'p': {'where': 'p_max > 0'}}}}) + p = laid.variables['p'] + assert (p.dims, p.bounds.lower, p.bounds.upper, p.where) == (['snapshot', 'generator'], 0, 'p_max', 'p_max > 0'), ( + 'the fields the patch does not name are the ones the base declared' + ) + + +def test_the_base_and_the_patches_are_never_mutated(): + """The guarantee belongs to the function rather than to the caller's discipline.""" + patches = {'carbon': CARBON, 'operate': {'variables': {'p': {'where': 'p_max > 0'}}}} + before = copy.deepcopy((DISPATCH_MODEL, patches)) + override(DISPATCH_MODEL, patches) + assert before == (DISPATCH_MODEL, patches), 'a patched base is a new mapping, and both inputs are untouched' + + +def test_a_whole_declaration_is_created_and_the_model_loads(): + spec = override(DISPATCH_MODEL, {'carbon': CARBON}) + assert 'co2_cap' in spec.constraints + assert to_markdown(spec), 'a composed spec is one a reviewer can read as math' + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param({'constraints': {'balnce': {'dims': ['snapshot']}}}, "Did you mean 'balance'?", id='a-near-miss'), + pytest.param( + {'constraints': {'co2_cap': {'dims': []}}}, 'a constraint needs `expression`', id='short-of-a-field' + ), + pytest.param({'parameters': {'co2': {'dtype': 'float'}}}, 'a parameter needs `dims`', id='short-of-its-frame'), + pytest.param( + {'expressions': {'spend': {'dims': ['snapshot']}}}, + 'one `expression:` or a set of `cases:`', + id='short-of-what-it-says', + ), + pytest.param( + {'given': {'variables': {'flow': {'domain': 'binary'}}}}, + 'a given variable needs `dims`', + id='a-given-column-short-of-its-frame', + ), + ], +) +def test_a_partial_entry_that_lands_on_nothing_is_refused(patch, says): + """The typo case: laying a partial entry on nothing would invent a declaration nothing refers to.""" + with pytest.raises(LanguageError, match=r'does not declare') as raised: + override(DISPATCH_MODEL, {'project': patch}) + assert says in str(raised.value), 'the refusal says what the entry is short of, or what it nearly named' + + +def test_a_null_removes_a_declaration_and_the_model_still_loads(): + laid = override(DISPATCH_MODEL, {'unconstrained': {'constraints': {'balance': None}}}) + assert laid.constraints == {}, 'the declaration is gone rather than emptied' + + +def test_a_stale_removal_is_refused(): + with pytest.raises(LanguageError, match=r"'balnce'.*does not declare.*Did you mean 'balance'\?"): + override(DISPATCH_MODEL, {'stale': {'constraints': {'balnce': None}}}) + + +@pytest.mark.parametrize( + ('patch', 'field', 'default'), + [ + pytest.param({'variables': {'p': {'where': None}}}, lambda s: s.variables['p'].where, None, id='a-mask'), + pytest.param( + {'variables': {'p': {'bounds': {'upper': None}}}}, + lambda s: s.program.variables['p'].upper, + None, + id='a-bound-two-levels-down', + ), + pytest.param( + {'variables': {'p': {'domain': None}}}, + lambda s: s.variables['p'].domain, + 'continuous', + id='a-field-that-takes-no-null', + ), + pytest.param({'version': None}, lambda s: s.version, 0, id='a-top-level-field-that-takes-no-null'), + ], +) +def test_a_null_field_takes_its_default(patch, field, default): + """`null` makes what it names absent: a declaration is removed, and a field takes its default. + + A field that takes no null was the case that failed: `domain: null` was laid + as a value the schema refuses, so a patch could not put a field back to its + default. + """ + base = varied( + DISPATCH_MODEL, + **{'description': 'dispatch', 'variables.p.where': 'p_max > 0', 'variables.p.domain': 'integer'}, + ) + laid = override(base, {'relaxed': patch}) + assert field(laid) == default + assert laid.variables['p'].bounds.lower == 0, 'a field the patch does not name stays as the base wrote it' + + +@pytest.mark.parametrize( + 'patches', + [ + pytest.param( + { + 'pathway': {'variables': {'p': {'bounds': {'upper': 'p_max'}}}}, + 'project': {'variables': {'p': {'bounds': {'upper': 'cost'}}}}, + }, + id='one-field-twice', + ), + pytest.param( + { + 'pathway': {'constraints': {'balance': None}}, + 'project': {'constraints': {'balance': {'dims': ['snapshot', 'generator']}}}, + }, + id='removed-here-edited-there', + ), + pytest.param( + {'pathway': {'objective': {'sense': 'maximize'}}, 'project': {'objective': {'sense': 'minimize'}}}, + id='the-objective-twice', + ), + pytest.param( + {'pathway': {'version': 0}, 'project': {'version': 1}}, + id='a-top-level-scalar-twice', + ), + ], +) +def test_two_patches_that_write_one_field_are_refused(patches): + with pytest.raises(LanguageError) as raised: + override(DISPATCH_MODEL, patches) + message = str(raised.value) + assert "'pathway'" in message and "'project'" in message, 'a collision names both patches, not just the second' + assert 'override(override(' in message, 'the message names the rewrite, which is to lay one on the other' + + +def test_disjoint_patches_compose_the_same_model_in_either_order(): + """What the disjointness rule buys: the argument's position never decides a spec.""" + patches = {'carbon': CARBON, 'operate': {'variables': {'p': {'where': 'p_max > 0'}}}} + reversed_order = dict(reversed(list(patches.items()))) + assert override(DISPATCH_MODEL, patches) == override(DISPATCH_MODEL, reversed_order) + + +def test_layering_is_written_out_as_nesting(): + """The second call lays on the first's result, which is where an order is allowed to matter.""" + once = override(DISPATCH_MODEL, {'pathway': {'variables': {'p': {'where': 'p_max > 0'}}}}) + twice = override(once, {'project': {'variables': {'p': {'where': 'cost > 0'}}}}) + assert twice.variables['p'].where == 'cost > 0' + + +def test_a_patch_adds_a_dimension_and_may_restate_one_it_shares(): + laid = override( + DISPATCH_MODEL, + {'periods': {'dimensions': {'snapshot': {'dtype': 'int'}, 'investment_period': {'dtype': 'int'}}}}, + ) + assert sorted(laid.dimensions) == ['generator', 'investment_period', 'snapshot'], ( + 'the dimension the patch adds joins the two the base declares, and the restated one is not doubled' + ) + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param( + {'dimensions': {'snapshot': {'dtype': 'str'}}}, + 'adjusts the math, not the coordinate space', + id='declared-as-something-else', + ), + pytest.param( + {'dimensions': {'snapshot': {}}}, + 'restate the declaration word for word', + id='restated-in-part', + ), + pytest.param( + {'dimensions': {'snapshot': None}}, + 'remove the declarations written over it one at a time', + id='removed', + ), + ], +) +def test_a_patch_that_rewrites_a_dimension_is_refused(patch, says): + """A dimension changed under the expressions already written over it is a different spec, silently.""" + with pytest.raises(LanguageError) as raised: + override(DISPATCH_MODEL, {'relabelled': patch}) + assert says in str(raised.value), 'the refusal names the rewrite rather than only what is wrong' + + +@pytest.mark.parametrize( + 'patch', + [ + pytest.param({'constraints': None}, id='an-owned-section'), + pytest.param({'dimensions': None}, id='a-shared-section'), + pytest.param({'given': {'variables': None}}, id='one-kind-of-given'), + ], +) +def test_a_whole_section_set_to_null_is_refused(patch): + """Nulling a section reads as emptying it, and laying it silently changed nothing at all.""" + with pytest.raises(LanguageError, match=r'removes nothing') as raised: + override(DISPATCH_MODEL, {'blank': patch}) + assert 'one at a time' in str(raised.value), 'the refusal names the rewrite, which is one null per declaration' + + +def test_the_objective_is_laid_over_field_by_field(): + laid = override(DISPATCH_MODEL, {'maximised': {'objective': {'sense': 'maximize'}}}) + assert laid.objective is not None + assert (laid.objective.sense, laid.objective.expression) == ('maximize', 'sum(p * cost)'), ( + 'the sense the patch names changes, and the expression the base wrote stays' + ) + + +def test_the_objective_can_be_removed_and_the_model_is_a_feasibility_problem(): + laid = override(DISPATCH_MODEL, {'feasible': {'objective': None}}) + assert laid.objective is None + + +def test_a_whole_objective_is_created_where_the_base_has_none(): + laid = override(FEASIBILITY, {'priced': {'objective': DISPATCH_MODEL['objective']}}) + assert laid.objective is not None + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param({'objective': None}, 'already the feasibility problem', id='removing-one-that-is-not-there'), + pytest.param( + {'objective': {'sense': 'maximize'}}, 'an objective needs `expression`', id='editing-one-that-is-not-there' + ), + ], +) +def test_an_objective_a_base_does_not_declare_is_refused(patch, says): + with pytest.raises(LanguageError, match=r'does not declare') as raised: + override(FEASIBILITY, {'project': patch}) + assert says in str(raised.value) + + +def test_a_patch_over_one_kind_of_given_leaves_the_other_alone(): + """`given:` is laid over a kind at a time, so patching the columns cannot drop the row families.""" + laid = override(GIVEN_BASE, {'wider': {'given': {'variables': {'p': {'domain': 'binary'}}}}}) + p = laid.given.variables['p'] + assert (p.dims, p.domain) == (['g'], 'binary'), 'the given column is edited field by field like any declaration' + assert sorted(laid.given.constraints) == ['cap'], 'the kind the patch did not name is still there' + + +@pytest.mark.parametrize( + ('base', 'reads'), + [ + pytest.param(GIVEN_BASE, ['p', 'q'], id='a-base-that-already-reads'), + pytest.param({'dimensions': {'g': {'dtype': 'str'}}}, ['q'], id='a-base-that-reads-nothing-yet'), + ], +) +def test_a_whole_given_entry_is_created_and_the_model_loads(base, reads): + """A patch adds a column to read, whether or not the base opened the block.""" + laid = override(base, {'solved': {'given': {'variables': {'q': {'dims': ['g']}}}}}) + assert sorted(laid.given.variables) == reads, 'the created column joins whatever the base read' + + +def test_a_patch_is_a_path_as_readily_as_a_mapping(tmp_path): + """Whatever every other verb takes, so a patch travels as a file rather than as a script.""" + patch = tmp_path / 'carbon.yaml' + patch.write_text('parameters:\n co2: {dims: [generator]}\n', encoding='utf-8') + laid = override(DISPATCH_MODEL, {'carbon': str(patch)}) + assert 'co2' in laid.parameters + + +def test_a_base_that_does_not_load_is_refused_though_a_patch_would_mend_it(): + """A base is a spec a framework ships, so one that does not load is refused before a patch reaches it.""" + unpriced = {**DISPATCH_MODEL, 'parameters': {k: v for k, v in DISPATCH_MODEL['parameters'].items() if k != 'cost'}} + with pytest.raises(LanguageError, match=r"'cost' not found"): + override(unpriced, {'priced': {'parameters': {'cost': {'dims': ['generator']}}}}) + + +def test_a_loaded_spec_is_a_base_as_readily_as_a_mapping(): + laid = override(to_spec(DISPATCH_MODEL), {'carbon': CARBON}) + assert 'co2_cap' in laid.constraints + + +@pytest.mark.parametrize( + ('kind', 'entry'), + [ + pytest.param('parameters', {'dims': ['g'], 'dtype': 'bool'}, id='a-parameter'), + pytest.param('expressions', {'dims': ['g']}, id='an-expression'), + ], +) +def test_a_patch_creates_a_given_entry_of_every_kind(kind, entry): + """`override` lays `given:` over by the kinds `merge` folds, so a kind added to one reaches the other.""" + laid = override(GIVEN_BASE, {'reads': {'given': {kind: {'r': entry}}}}) + assert getattr(laid.given, kind)['r'].dims == ['g'] + assert sorted(laid.given.variables) == ['p'], 'the kinds the patch did not name are still there' diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index b280a61b..082b2dc9 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -15,7 +15,7 @@ from mathspec.program import Mask, RelationPairComparison from mathspec.resolution import Namespace from mathspec.validation import to_spec -from tests.fixtures import expression_of, override, schema_of, where_of +from tests.fixtures import expression_of, schema_of, varied, where_of if TYPE_CHECKING: from mathspec.spec import Spec @@ -453,7 +453,7 @@ class TestTheEdgeRulesAreDecidedAtLoad: def _refused(self, expression: str) -> str: """The message `to_spec` refuses *expression* with — a `SchemaError`, since every rule here is resolution's.""" - raw = override(self.BASE, **{'constraints.k.expression': expression}) + raw = varied(self.BASE, **{'constraints.k.expression': expression}) with pytest.raises(SchemaError) as caught: to_spec(raw) return str(caught.value) @@ -507,7 +507,7 @@ def test_a_zero_step_vacates_nothing_and_needs_no_edge(self): literal zero vacates none, so there is nothing for an `edge=` to answer for. A *named* offset may be zero in the data and is not known here. """ - to_spec(override(self.BASE, **{'constraints.k.expression': 'p <= shift(cap, along=g, offset=0)'})) + to_spec(varied(self.BASE, **{'constraints.k.expression': 'p <= shift(cap, along=g, offset=0)'})) # --------------------------------------------------------------------------- diff --git a/tests/test_expand.py b/tests/test_expand.py index 7919bcdd..49b1a90e 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -18,7 +18,7 @@ import pytest from mathspec import piecewise, to_spec -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, schema_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, schema_of, varied from tests.test_sos import CURVE from tools.render_tex import models @@ -27,7 +27,7 @@ #: The curve masked by one of its own values parameters, the one block whose #: rows sit on more than the file's own names. -MASKED = override( +MASKED = varied( CURVE, **{ 'piecewise.cost_curve.method': 'lp', diff --git a/tests/test_given.py b/tests/test_given.py new file mode 100644 index 00000000..0961d42f --- /dev/null +++ b/tests/test_given.py @@ -0,0 +1,578 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""What a file reads and does not build: a parameter, a column, a named expression, and a row family. + +A fragment reads a column the file beside it introduces, and `merge` folds the +two together, so the composed spec carries no trace of the reading. A layer +reads a column, or the dual of a row family, that a model outside the language +holds, so there is nothing to fold into and the program carries the name for a +host model to provide. What both need is that the file stands on its own: it loads, +it lowers, and it prints as math, without the thing that owns what it reads. +""" + +from __future__ import annotations + +import pytest + +from mathspec import FORMATS, LanguageError, advice, merge, to_markdown, to_spec, typeset + +#: One component file: it pins the flow at its own port, and the column it +#: pins belongs to another fragment. +SUPPLY = { + 'description': 'A fleet of generators, each on one port.', + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'values': 'port'}}, + 'given': {'variables': {'flow': {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus'}}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': { + 'gen_injects': { + 'dims': ['snapshot', 'generator'], + 'expression': 'at(flow, by=gen_port, over=port, into=generator) == gen_p', + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +#: The fragment that introduces `flow`, with the bounds and the balance that go with it. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'values': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port'], 'bounds': {'lower': -1000, 'upper': 1000}}}, + 'constraints': { + 'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus, over=port, into=bus) == 0'} + }, +} + + +def test_given_holds_four_kinds_and_refuses_a_fifth(): + """The section is closed, so a kind nobody has admitted yet is the schema's own refusal.""" + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given': {'macros': {'twice': {'params': ['x'], 'template': '2 * x'}}}}) + assert 'Valid keys: constraints, expressions, parameters, variables' in str(raised.value), ( + 'the refusal names what the block takes' + ) + + +def test_a_fragment_that_says_what_it_reads_loads_on_its_own(): + spec = to_spec(SUPPLY) + assert sorted(spec.given.variables) == ['flow'], 'the column it reads is a declaration like any other' + assert sorted(spec.variables) == ['gen_p'], 'and it is not one of the columns this file introduces' + + +def test_a_given_name_is_held_to_the_name_rule(): + """`_names_are_names` walks the top-level mappings, and `given:` nests its two one level down.""" + with pytest.raises(LanguageError, match=r"given: variables: 'no-flow' is not a name"): + to_spec({**SUPPLY, 'given': {'variables': {'no-flow': {'dims': ['snapshot', 'port']}}}}) + + +def test_a_whole_model_writes_no_given_block(): + whole = to_spec({**SUPPLY, 'given': {}, 'constraints': {}}) + assert 'given' not in whole.to_dict(), 'an empty section is an absence, and is left out' + + +def test_a_fragment_round_trips_through_its_own_data(): + spec = to_spec(SUPPLY) + assert to_spec(spec.to_dict()) == spec + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_fragment_prints_as_math_in_every_format(fmt): + assert typeset(SUPPLY, fmt), f'{fmt} rendered nothing' + + +def test_the_given_column_prints_under_its_own_heading(): + printed = to_markdown(SUPPLY) + assert '#### Given' in printed, 'the legend says which symbols the file does not introduce' + assert '`flow`' in printed.split('#### Given')[1] + + +def test_a_program_carries_the_column_it_reads_apart_from_the_ones_it_builds(): + """The distinction a builder needs: create this column, or use the one the host model provides.""" + program = to_spec(SUPPLY).program + assert sorted(program.variables) == ['gen_p'], 'a build reads this group and creates a column for each' + assert sorted(program.given.variables) == ['flow'], ( + 'and uses, for each of these, the column the host model provides' + ) + assert program.given.variables['flow'].dims == ('snapshot', 'port'), ( + 'the frame is what a consumer checks against the host' + ) + + +def test_what_a_program_reads_is_sealed_like_what_it_builds(): + program = to_spec(SUPPLY).program + with pytest.raises(TypeError, match='does not support item assignment'): + program.given.variables['p'] = program.given.variables['flow'] + + +def test_the_advice_says_which_columns_a_consumer_has_to_bind(): + (note,) = [note for note in advice(SUPPLY) if note.kind == 'given'] + assert note.subject == 'flow' + + +def test_a_given_column_in_the_objective_is_not_advised_unbounded(): + """The unboundedness pass reads a variable's bounds, and a given column's bounds are the owner's.""" + priced = {**SUPPLY, 'constraints': {}, 'objective': {'sense': 'minimize', 'expression': 'sum(flow)'}} + assert not [note for note in advice(priced) if note.kind == 'unbounded'] + + +def test_a_name_both_introduced_and_given_in_one_file_is_refused(): + both = {**SUPPLY, 'variables': {**SUPPLY['variables'], 'flow': {'dims': ['snapshot', 'port']}}} + with pytest.raises(LanguageError, match=r"Given variable 'flow' collides with the variable"): + to_spec(both) + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': ['snapshot', 'nowhere']}, 'nowhere', id='a-frame-over-an-undeclared-dimension'), + pytest.param({'dims': ['snapshot', 'snapshot']}, 'twice', id='a-frame-naming-one-dimension-twice'), + pytest.param({'dims': ['snapshot'], 'bounds': {'lower': 0}}, 'bounds', id='bounds-the-owner-holds'), + pytest.param({'dims': ['snapshot'], 'where': 'gen_cost > 0'}, 'where', id='a-mask-the-owner-holds'), + ], +) +def test_a_given_declaration_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given': {'variables': {'flow': block}}}) + assert says in str(raised.value) + + +def test_an_expression_reads_a_given_column_as_it_reads_any_other(): + """Resolution and the dim algebra see one namespace, so the walk lands on the generator frame.""" + spec = to_spec(SUPPLY) + assert spec.constraints['gen_injects'].dims == ['snapshot', 'generator'] + + +def test_merging_folds_the_given_declaration_into_the_one_that_introduces_it(): + spec = merge({'surface': SURFACE, 'supply': SUPPLY}) + assert not spec.given, 'the expectation is spent once the column is in the composition' + assert sorted(spec.variables) == ['flow', 'gen_p'] + assert spec.variables['flow'].bounds.lower == -1000, "the introducer's declaration is the one that survives" + assert sorted(to_spec(spec).program.variables) == ['flow', 'gen_p'], 'a composed library lowers like any spec' + + +@pytest.mark.parametrize( + 'reads', + [ + pytest.param({'dims': ['snapshot', 'port']}, id='the-frame-alone'), + pytest.param({'dims': ['snapshot', 'port'], 'domain': 'continuous'}, id='the-domain-the-introducer-defaults'), + pytest.param({'dims': ['snapshot', 'port'], 'description': 'the flow, in my words'}, id='its-own-prose'), + ], +) +def test_a_given_declaration_may_say_less_than_the_introducer(reads): + """Bounds are the introducer's, so the reader states the frame and stops.""" + composed = merge({'surface': SURFACE, 'supply': {**SUPPLY, 'given': {'variables': {'flow': reads}}}}) + assert composed.variables['flow'].bounds.upper == 1000 + + +#: A fragment that reads `flow` over `port` alone, and loads so: it only +#: declares what it reads. +PORTS_ONLY = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['port']}}}, +} + + +@pytest.mark.parametrize( + 'misread', + [ + pytest.param(PORTS_ONLY, id='another-frame'), + pytest.param( + {**SUPPLY, 'given': {'variables': {'flow': {'dims': ['snapshot', 'port'], 'domain': 'binary'}}}}, + id='another-domain', + ), + ], +) +def test_a_given_declaration_that_disagrees_with_the_introducer_is_refused(misread): + with pytest.raises(LanguageError, match=r'says the same as the declaration it is folded into, or less') as raised: + merge({'surface': SURFACE, 'supply': misread}) + message = str(raised.value) + assert "'supply'" in message and "'surface'" in message, 'both sides of a disagreement are named' + + +def test_two_fragments_must_read_one_column_the_same_way(): + with pytest.raises(LanguageError, match=r'say different things about the given variable'): + merge({'supply': SUPPLY, 'other': PORTS_ONLY}) + + +@pytest.mark.parametrize( + ('given', 'says'), + [ + pytest.param( + {'variables': {**SUPPLY['given']['variables'], 'gen_p': {'dims': ['snapshot', 'generator']}}}, + "Given variable 'gen_p' collides with the variable", + id='a-column-it-builds', + ), + pytest.param( + {**SUPPLY['given'], 'constraints': {'gen_injects': {'dims': ['snapshot', 'generator']}}}, + "Given constraint 'gen_injects' is also declared under 'constraints:'", + id='a-row-family-it-builds', + ), + ], +) +def test_a_fragment_that_reads_what_it_builds_is_refused(given, says): + """`to_spec` refuses such a file, so a composition refuses it too rather than folding the reading away.""" + with pytest.raises(LanguageError, match=r"fragment 'supply' does not load on its own") as raised: + merge({'surface': SURFACE, 'supply': {**SUPPLY, 'given': given}}) + assert says in str(raised.value), "the fragment's own refusal names the name it reads twice" + + +def test_a_given_declaration_nothing_introduces_stays_for_a_consumer_to_bind(): + composed = merge({'supply': SUPPLY, 'other': {'dimensions': {'snapshot': {'dtype': 'int'}}}}) + assert composed.given.variables['flow'].dims == ['snapshot', 'port'], 'a name nothing introduces is still read' + assert sorted(composed.program.given.variables) == ['flow'] + + +#: A layer over a model this language never sees: it reads a column and the +#: dual of a row family, and adds one constraint of its own. +LAYER = { + 'description': 'A carbon cap laid over a model that already exists.', + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'description': 'the host clears each bus'}}, + }, + 'parameters': {'rate': {'dims': ['bus']}}, + 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, +} + + +def test_a_dual_may_name_a_row_family_this_file_does_not_build(): + spec = to_spec(LAYER) + assert sorted(spec.given.constraints) == ['balance'] + assert sorted(spec.constraints) == ['cap'], 'the row families it builds are its own, and that is not one' + + +def test_the_program_carries_the_row_family_a_consumer_binds(): + program = to_spec(LAYER).program + assert sorted(program.given.constraints) == ['balance'] + assert program.given.constraints['balance'].dims == ('snapshot', 'bus'), ( + 'the frame is what a consumer checks against the host' + ) + + +def test_the_dual_takes_its_frame_from_the_given_declaration(): + """Without the frame the reported expression has no dims, and nothing downstream could shape it.""" + assert to_markdown(LAYER).count(r'\lambda_{\mathrm{balance},t,b}') == 1 + + +def test_a_given_row_family_prints_under_the_given_heading(): + given = to_markdown(LAYER).split('#### Given')[1] + assert '`balance`' in given + assert 'reads the dual of' in given, 'the legend says what the file may do with it' + + +def test_a_row_family_both_built_and_given_is_refused(): + both = {**LAYER, 'constraints': {**LAYER['constraints'], 'balance': {'dims': [], 'expression': 'sum(p) >= 0'}}} + with pytest.raises(LanguageError, match=r"'balance'.*either built by this file or given to it"): + to_spec(both) + + +def test_a_dual_naming_nothing_says_where_to_declare_it(): + mistyped = {**LAYER, 'expressions': {'price': {'expression': 'dual(balnce)'}}} + with pytest.raises(LanguageError) as raised: + to_spec(mistyped) + message = str(raised.value) + assert "'constraints:'" in message and "'given: constraints:'" in message, ( + 'the message names both places the row family could be declared' + ) + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': [], 'expression': 'sum(p) >= 0'}, 'expression', id='a-body-the-owner-holds'), + pytest.param({'dims': [], 'sense': '<='}, 'sense', id='a-sense-nothing-here-could-check'), + ], +) +def test_a_given_row_family_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**LAYER, 'given': {**LAYER['given'], 'constraints': {'balance': block}}}) + assert says in str(raised.value) + + +def test_merging_folds_a_row_family_into_the_file_that_builds_it(): + builder = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'variables': {'p': {'dims': ['snapshot', 'bus'], 'bounds': {'lower': 0}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'p >= 0'}}, + } + composed = merge({'builder': builder, 'layer': LAYER}) + assert not composed.given + program = composed.program + assert sorted(program.constraints) == ['balance', 'cap'] + assert not program.given.constraints, 'nothing is left for a host model to provide' + + +def test_the_advice_names_every_declaration_a_consumer_has_to_bind(): + subjects = {note.subject for note in advice(LAYER) if note.kind == 'given'} + assert subjects == {'p', 'balance'}, 'both the column and the row family are named' + + +#: `port` is named by nothing but the given column's frame, and `bus` by +#: nothing but the given row family's, so each is in use only through a +#: declaration this file does not build. +REACHED_ONLY_BY_A_GIVEN_FRAME = { + 'dimensions': {'g': {'dtype': 'str'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['port']}}, 'constraints': {'balance': {'dims': ['bus']}}}, + 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, + 'constraints': {'tie': {'dims': ['g'], 'expression': 'p >= sum(flow, over=port)'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, +} + + +def test_a_dimension_only_a_given_declaration_indexes_is_in_use(): + """The never-an-axis pass reads the frames a build emits, and these two are in neither.""" + unreached = {note.subject for note in advice(REACHED_ONLY_BY_A_GIVEN_FRAME) if note.kind == 'never-an-axis'} + assert not unreached, 'a dimension a given column or row family is indexed by is used' + + +# --------------------------------------------------------------------------- +# a parameter another file declares +# --------------------------------------------------------------------------- + +#: A cost file: it reads the fleet's output and its price, and declares +#: neither. The price is data the fleet file declares. +PRICED = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {'dtype': 'str'}}, + 'given': { + 'parameters': { + 'gen_cost': {'dims': ['generator'], 'description': 'what one unit of output costs'}, + 'gen_on': {'dims': ['generator'], 'dtype': 'bool'}, + }, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator']}}, + }, + 'parameters': {'weight': {'dims': ['snapshot']}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost * weight)'}, + 'constraints': { + 'off_units_idle': {'dims': ['snapshot', 'generator'], 'where': 'not gen_on', 'expression': 'gen_p <= 0'} + }, +} + + +def test_a_fragment_reads_a_parameter_it_does_not_declare(): + spec = to_spec(PRICED) + assert sorted(spec.given.parameters) == ['gen_cost', 'gen_on'] + assert sorted(spec.parameters) == ['weight'], 'the data it declares is its own, and the price is not' + + +def test_a_program_carries_the_parameter_it_reads_apart_from_the_ones_it_declares(): + program = to_spec(PRICED).program + assert sorted(program.parameters) == ['weight'] + assert program.given.parameters['gen_on'].dims == ('generator',) + assert program.given.parameters['gen_on'].dtype == 'bool', 'a where compares against the dtype the reader states' + + +def test_a_given_parameter_is_read_in_a_bound_as_any_parameter_is(): + bounded = { + **PRICED, + 'given': {**PRICED['given'], 'parameters': {'gen_p_max': {'dims': ['generator']}}}, + 'variables': {'spill': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': {}, + 'objective': {'sense': 'minimize', 'expression': 'sum(spill + gen_p)'}, + } + assert to_spec(bounded).variables['spill'].bounds.upper == 'gen_p_max' + + +def test_a_given_parameter_prints_under_the_given_heading(): + given = to_markdown(PRICED).split('#### Given')[1] + assert '`gen_cost`' in given and '`gen_on`' in given + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': ['nowhere']}, 'nowhere', id='a-frame-over-an-undeclared-dimension'), + pytest.param({'dims': ['generator'], 'default': 0}, 'default', id='a-field-a-parameter-does-not-have'), + ], +) +def test_a_given_parameter_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**PRICED, 'given': {**PRICED['given'], 'parameters': {'gen_cost': block}}}) + assert says in str(raised.value) + + +def test_a_parameter_both_declared_and_given_in_one_file_is_refused(): + both = {**PRICED, 'parameters': {**PRICED['parameters'], 'gen_cost': {'dims': ['generator']}}} + with pytest.raises(LanguageError, match=r"Given parameter 'gen_cost' collides with the parameter"): + to_spec(both) + + +#: The fleet file, which declares the data `PRICED` reads. +FLEET = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {'dtype': 'str'}}, + 'parameters': { + 'gen_cost': {'dims': ['generator']}, + 'gen_on': {'dims': ['generator'], 'dtype': 'bool'}, + 'gen_p_max': {'dims': ['generator']}, + }, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, +} + + +def test_merging_folds_a_given_parameter_into_the_declaration(): + composed = merge({'fleet': FLEET, 'cost': PRICED}) + assert not composed.given, 'every reading is spent once the fleet is in the composition' + assert sorted(composed.parameters) == ['gen_cost', 'gen_on', 'gen_p_max', 'weight'] + + +@pytest.mark.parametrize( + 'misread', + [ + pytest.param({'dims': ['snapshot', 'generator']}, id='another-frame'), + pytest.param({'dims': ['generator'], 'dtype': 'int'}, id='another-dtype'), + ], +) +def test_a_given_parameter_that_disagrees_with_the_declaration_is_refused(misread): + cost = {**PRICED, 'given': {**PRICED['given'], 'parameters': {**PRICED['given']['parameters'], 'gen_on': misread}}} + with pytest.raises(LanguageError, match=r"reads the given parameter 'gen_on' as"): + merge({'fleet': FLEET, 'cost': cost}) + + +def test_a_given_parameter_nothing_declares_stays_for_the_data_to_bind(): + composed = merge({'cost': PRICED, 'other': {'dimensions': {'snapshot': {'dtype': 'int'}}}}) + assert sorted(composed.program.given.parameters) == ['gen_cost', 'gen_on'] + + +def test_the_advice_names_a_given_parameter_as_data_a_consumer_binds(): + notes = {note.subject: note.text for note in advice(PRICED) if note.kind == 'given'} + assert 'gen_cost' in notes and 'parameter' in notes['gen_cost'] + + +# --------------------------------------------------------------------------- +# a named expression another file defines +# --------------------------------------------------------------------------- + +#: A balance file: it clears each bus of what every component injects, and +#: defines none of the injections. +BALANCE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'given': { + 'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'description': 'what the components put into a bus'}} + }, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, +} + +#: A component file that defines the injection. +INJECTOR = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, + 'expressions': {'injection': {'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus) - load'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + + +def test_a_fragment_reads_an_expression_it_does_not_define(): + spec = to_spec(BALANCE) + assert sorted(spec.given.expressions) == ['injection'] + assert spec.constraints['balance'].dims == ['snapshot', 'bus'], 'the frame the reader states is the one read' + + +def test_a_program_carries_the_expression_it_reads_apart_from_the_ones_it_defines(): + program = to_spec(BALANCE).program + assert not program.expressions + assert program.given.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_given_expression_prints_under_the_given_heading(): + given = to_markdown(BALANCE).split('#### Given')[1] + assert '`injection`' in given + assert 'an expression another file defines' in given, 'the legend says what kind of thing the file reads' + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_fragment_reading_an_expression_prints_in_every_format(fmt): + assert typeset(BALANCE, fmt), f'{fmt} rendered nothing' + + +def test_a_given_expression_is_no_mask(): + """A mask is built before any variable exists, and a given expression may read variables.""" + masked = {**BALANCE, 'constraints': {'balance': {**BALANCE['constraints']['balance'], 'where': 'injection > 0'}}} + with pytest.raises(LanguageError, match='before variables exist'): + to_spec(masked) + + +def test_an_expression_both_defined_and_given_in_one_file_is_refused(): + both = {**BALANCE, 'expressions': {'injection': {'expression': '0'}}} + with pytest.raises(LanguageError, match=r"Given expression 'injection' collides with the named expression"): + to_spec(both) + + +def test_merging_folds_a_given_expression_into_its_definition(): + composed = merge({'balance': BALANCE, 'injector': INJECTOR}) + assert not composed.given + assert composed.constraints['balance'].expression == 'injection == 0' + assert composed.program.expressions['injection'].in_math, 'the balance reads the definition once folded' + + +def test_a_definition_over_a_dimension_the_reader_does_not_state_is_refused(): + """The reader's frame bounds what it reads, so a body carrying more is a disagreement `merge` names.""" + narrow = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} + with pytest.raises(LanguageError, match=r"'balance' reads the given expression 'injection' over \['bus'\]"): + merge({'balance': narrow, 'injector': INJECTOR}) + + +#: An injector whose output does not vary by snapshot: its injection is over `bus` alone. +FLAT_INJECTOR = { + 'dimensions': {'bus': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'variables': {'gen_p': {'dims': ['generator'], 'bounds': {'lower': 0}}}, + 'expressions': {'injection': {'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + + +def test_a_definition_over_fewer_dimensions_merges_where_the_row_carries_the_rest(): + """The reader's `dims` is an upper bound: a row that carries `snapshot` through another term is sound.""" + demand = { + **BALANCE, + 'parameters': {'demand': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == demand'}}, + } + composed = merge({'balance': demand, 'injector': FLAT_INJECTOR}) + assert composed.program.expressions['injection'].dims == ('bus',) + + +def test_the_composed_load_refuses_a_row_a_narrower_definition_repeats(): + """With nothing else carrying `snapshot`, the row would repeat per snapshot, which the composed spec refuses.""" + with pytest.raises(LanguageError, match='repeated across'): + merge({'balance': BALANCE, 'injector': FLAT_INJECTOR}) + + +def test_a_given_expression_a_sibling_introduces_as_a_variable_is_refused(): + as_column = { + **INJECTOR, + 'expressions': {}, + 'variables': {**INJECTOR['variables'], 'injection': {'dims': ['snapshot', 'bus']}}, + } + with pytest.raises( + LanguageError, + match=r"reads 'injection' as a given expression, where 'injector' introduces it under 'variables:'", + ): + merge({'balance': BALANCE, 'injector': as_column}) + + +def test_the_composed_model_holds_a_definition_to_the_rules_of_where_it_is_read(): + """A fragment reads a given expression as a column, so a square of it is quadratic there and quartic once folded.""" + squares = { + **BALANCE, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection * injection <= 1'}}, + } + squared = { + **INJECTOR, + 'expressions': {'injection': {'expression': 'sum(gen_p * gen_p, by=gen_bus, over=generator, into=bus)'}}, + } + assert to_spec(squares) and to_spec(squared), 'each file loads on its own' + with pytest.raises(LanguageError, match='degree'): + merge({'balance': squares, 'injector': squared}) + + +def test_the_advice_names_a_given_expression(): + notes = {note.subject: note.text for note in advice(BALANCE) if note.kind == 'given'} + assert 'injection' in notes and 'expression' in notes['injection'] diff --git a/tests/test_library_example.py b/tests/test_library_example.py new file mode 100644 index 00000000..07de466d --- /dev/null +++ b/tests/test_library_example.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""The component library under `examples/library/`, held to what its pages claim. + +The gallery test holds each page to its generator, so the math on a page cannot +drift from the file above it. What is left for here is what no page states: that +every fragment stands alone, that the composition is one spec, and that the +variant patch, the one file in the library that is not a spec, applies to what +the fragments make. +""" + +from __future__ import annotations + +import pytest + +from mathspec import LanguageError, merge, override, to_markdown, to_spec +from mathspec.typesetting import FORMATS, typeset +from tests.fixtures import EXAMPLES +from tools import gallery + +LIBRARY = EXAMPLES / 'library' +FRAGMENTS = {name: LIBRARY / f'{name}.yaml' for name in ('surface', 'generator', 'load')} +PATCH = LIBRARY / 'variants' / 'commitment.yaml' +PATCHED = override(merge(FRAGMENTS), {'commitment': PATCH}) + + +@pytest.mark.parametrize('name', sorted(FRAGMENTS)) +def test_every_fragment_loads_and_prints_on_its_own(name): + """The unit a library ships is the unit somebody reviews, so each one is a spec.""" + assert to_markdown(to_spec(FRAGMENTS[name])), f'{name} rendered nothing' + + +@pytest.mark.parametrize('name', ['generator', 'load']) +def test_a_component_file_reads_the_surface_and_introduces_no_flow(name): + spec = to_spec(FRAGMENTS[name]) + assert sorted(spec.given.variables) == ['Port_p'], 'the port flow is the one name a component file reads' + assert 'Port_p' not in spec.variables, 'the surface introduces the flow, and a component file only reads it' + + +def test_the_library_composes_into_one_model(): + spec = merge(FRAGMENTS) + assert sorted(spec.variables) == ['Generator_p', 'Port_p'], 'the composition declares each column once' + assert sorted(spec.constraints) == ['Bus_nodal_balance', 'Generator_injection', 'Load_withdrawal'], ( + 'the composition carries every row family of every fragment, and no other' + ) + assert not spec.given, 'each read is folded into the declaration that introduces it' + assert spec.objective is not None and spec.objective.expression == 'sum(Generator_p * Generator_marginal_cost)', ( + "the one fragment that priced anything carries the composed spec's objective, as it wrote it" + ) + + +@pytest.mark.parametrize( + 'names', + [ + pytest.param(('surface', 'load'), id='one component file'), + pytest.param(('surface', 'generator', 'load'), id='the whole library'), + ], +) +def test_the_balance_is_written_once_however_many_fragments_are_merged(names): + merged = merge({name: FRAGMENTS[name] for name in names}) + surface = to_spec(FRAGMENTS['surface']) + assert merged.constraints['Bus_nodal_balance'] == surface.constraints['Bus_nodal_balance'], ( + 'a component file pins the flow at its own port, so merging leaves the balance as the surface wrote it' + ) + + +def test_the_variant_is_a_patch_rather_than_a_model(): + """Which is why `render_tex` skips `variants/`: nothing there loads on its own.""" + with pytest.raises(LanguageError): + to_spec(PATCH) + + +def test_the_variant_patch_applies_to_the_composition(): + spec = to_spec(PATCHED) + assert spec.variables['Generator_status'].domain == 'binary' + assert spec.variables['Generator_p'].bounds.upper is None, 'the cap moves from the bound to a constraint' + assert sorted(spec.constraints) == [ + 'Bus_nodal_balance', + 'Generator_com_p_lower', + 'Generator_com_p_upper', + 'Generator_injection', + 'Load_withdrawal', + ], 'the patch adds its two rows and removes none' + + +@pytest.mark.parametrize('fmt', list(FORMATS), ids=list(FORMATS)) +def test_the_patched_model_prints_the_variant_math(fmt): + """A patch is read by the loader through the spec it lands on, so what it declares prints like the rest.""" + printed = typeset(to_spec(PATCHED), fmt).replace(r'\_', '_') + declared = ('Generator_status', 'Generator_p_min_pu', 'Generator_com_p_upper', 'Generator_com_p_lower') + missing = [name for name in declared if name not in printed] + assert not missing, f'the patch declares {missing}, and the typeset document does not name them' + + +def test_the_variant_needs_the_fragment_it_patches(): + """Picking commitment without the generator is a patch that lands on nothing, and it is refused at load.""" + without_generator = merge({name: FRAGMENTS[name] for name in ('surface', 'load')}) + with pytest.raises(LanguageError, match="edits the variable 'Generator_p', which its base does not declare"): + override(without_generator, {'commitment': PATCH}) + + +def test_every_variant_in_the_library_is_typeset_on_the_composed_page(): + """A patch prints only as the spec it lands on, so one with no tab is a patch nothing prints.""" + page = (gallery.PAGES / 'library' / 'composed.md').read_text() + missing = [path.name for path in (LIBRARY / 'variants').glob('*.yaml') if f'=== "With {path.stem}"' not in page] + assert not missing, f'the composed page gives {missing} no tab, so what they print is on no page' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index b33432b8..e92a7261 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -62,7 +62,7 @@ where_children, ) from mathspec.resolution import Namespace -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, expanded, expression_of, override, schema_of, where_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, expanded, expression_of, schema_of, varied, where_of DISPATCH_YAML = EXAMPLES / 'dispatch.yaml' @@ -71,7 +71,7 @@ #: One dimension, one parameter, one bounded variable and a scalar constraint: #: the smallest model that loads, for a claim about the plan's record rather -#: than about the math in it. A test adds what it judges with :func:`override`. +#: than about the math in it. A test adds what it judges with :func:`varied`. TINY = { 'dimensions': {'g': {}}, 'parameters': {'cost': {'dims': ['g']}}, @@ -89,7 +89,7 @@ #: offset. Which node a construct becomes is mostly a claim about the dim it #: consumes and the dim it lands on, and stating that needs a third dimension #: and two relations over one of them. -SHAPES_MODEL = override( +SHAPES_MODEL = varied( SMALL_MODEL, **{ 'dimensions.z': {'dtype': 'str'}, @@ -152,7 +152,7 @@ def test_program_structure(dispatch_program): @pytest.mark.parametrize('sense', [pytest.param('minimize', id='minimize'), pytest.param('maximize', id='maximize')]) def test_the_objective_sense_crosses_untranslated(sense: str): """One spelling from the file to the program, in both directions — each sink translates at its own edge.""" - program = to_spec(override(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})).program + program = to_spec(varied(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})).program assert program.objective is not None assert program.objective.sense == sense, "the file's own word for the direction, unchanged" @@ -290,7 +290,7 @@ def test_a_lowered_where_is_a_mask_that_answers_from_its_root(dispatch_program): def test_a_lowered_mask_answers_its_dims_conjuncts_and_atoms(variable, where, dims, conjuncts, atoms): """`Mask.dims` is read off the leaves, which carry their declarations' dims; `atoms` crosses the `OR` that `conjuncts` stops at.""" - mask = to_spec(override(SMALL_MODEL, **{f'variables.{variable}.where': where})).program.variables[variable].where + mask = to_spec(varied(SMALL_MODEL, **{f'variables.{variable}.where': where})).program.variables[variable].where assert mask.dims == frozenset(dims) assert len(mask.conjuncts) == conjuncts, 'an OR is one conjunct, a leaf is one conjunct' @@ -386,7 +386,7 @@ def test_an_unwritten_where_lowers_to_none_not_an_empty_mask(): def test_a_constraint_where_is_a_mask_like_a_variable_s(): - lowered = to_spec(override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})).program + lowered = to_spec(varied(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})).program (c,) = lowered.constraints.values() assert c.where == Mask(ParameterComparison('load', '>', 0.0, ('snapshot',))) @@ -395,7 +395,7 @@ def test_a_constraint_where_is_a_mask_like_a_variable_s(): def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides(): """The resolved tree holds the core syntax tree; the program holds the vocabulary a consumer reads, and every mask is rebuilt so.""" program = to_spec( - override( + varied( SHAPES_MODEL, **{ 'parameters.zc': {'dims': ['z']}, @@ -424,7 +424,7 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): """A comparison of expressions inside a count is rebuilt too, so a program mask is program vocabulary throughout.""" program = to_spec( - override( + varied( SHAPES_MODEL, **{'constraints.w': {'dims': ['g'], 'where': 'count(c <= 0.5 * k, over=g) >= 2', 'expression': 'p <= c'}}, ) @@ -440,7 +440,7 @@ def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): def test_a_translated_predicate_keeps_what_it_reads_in_reach(): """A walk that asks a mask what it names has to see through the translation, or the column is silently dropped.""" program = to_spec( - override( + varied( SHAPES_MODEL, **{ 'constraints.w': { @@ -460,7 +460,7 @@ def test_a_translated_predicate_keeps_what_it_reads_in_reach(): def test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_in_reach(): """The comparison under the read is rebuilt, and the relation is data the consumer attaches as well as the operand.""" program = to_spec( - override( + varied( SHAPES_MODEL, **{ 'parameters.zcap': {'dims': ['z']}, @@ -503,7 +503,7 @@ def test_assumptions_carry_the_file_s_entries_and_the_curves_behind_them(): def test_an_assumption_lowers_both_of_its_masks(): """The predicate and the ``where`` are rebuilt on program expressions, as every other mask is.""" - program = to_spec(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})).program + program = to_spec(varied(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})).program assumption = program.assumptions['sound'] assert assumption == Assumption( @@ -523,7 +523,7 @@ def test_an_assumption_refuses_in_the_words_the_file_wrote(): the sentence quotes it where the file wrote one. """ reason = 'a shape with no room between its bounds cannot be cut' - program = to_spec(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})).program + program = to_spec(varied(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})).program assumption = program.assumptions['sound'] assert assumption.description == reason, 'the program carries it, so a consumer needs no second read of the file' @@ -536,7 +536,7 @@ def test_a_cased_side_reads_the_data_its_regions_are_decided_by(): """`names_read` promised every parameter and relation the sides read, and dropped the `when:` of a cased entry: the walk descends a `Cases` by its values alone.""" program = to_spec( - override( + varied( SHAPES_MODEL, **{ 'expressions.e': { @@ -809,7 +809,7 @@ def test_walk_is_the_node_column_of_walk_regions(): def test_a_relation_is_declared_as_the_file_declares_it(): """One group keyed by name, each entry its columns and its key, and nothing nested under a dimension.""" program = to_spec( - override( + varied( TINY, dimensions={'g': {}, 'bus': {}, 'season': {}}, relations={ @@ -843,7 +843,7 @@ def test_a_program_seals_its_declaration_groups(dispatch_program, group): def test_roots_are_the_trees_a_row_is_built_from(): """`expressions` is the file's own section, which builds no row at all; the row-building trees are `roots`.""" program = to_spec( - override( + varied( TINY, expressions={'spend': 'sum(cost, over=g)'}, objective={'sense': 'minimize', 'expression': 'sum(p * cost, over=g)'}, @@ -864,7 +864,7 @@ def test_roots_are_the_trees_a_row_is_built_from(): def _footprint_of(constraint: str, objective: str) -> Footprint: return to_spec( - override( + varied( TINY, constraints={'k': {'dims': ['g'], 'expression': constraint}}, objective={'sense': 'minimize', 'expression': objective}, @@ -916,7 +916,7 @@ def test_the_footprint_is_walked_once_and_held(dispatch_program): def test_a_named_expression_is_not_in_the_footprint(): """It builds no row, so counting it would answer wrongly about what is solved.""" - program = to_spec(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'})).program + program = to_spec(varied(TINY, expressions={'spend': 'sum(p * cost, over=g)'})).program assert Parameter not in program.footprint.kinds, "the named expression's parameter reaches no row" assert Parameter in {type(n) for n in walk(program.expressions['spend'].expression)}, ( @@ -930,7 +930,7 @@ def test_a_dimension_carries_the_dtype_its_labels_are_checked_against(): A dimension is read from whatever table carries it, so nothing downstream can infer what the column should have been. """ - program = to_spec(override(TINY, **{'dimensions.t': {'dtype': 'int'}})).program + program = to_spec(varied(TINY, **{'dimensions.t': {'dtype': 'int'}})).program assert program.dimensions['t'].dtype == 'int', 'a declared dtype reaches the plan' assert program.dimensions['g'].dtype == 'str', "and the schema's default does too, rather than nothing" @@ -1054,14 +1054,14 @@ def test_a_cased_expression_is_readable_by_the_name_the_file_wrote(): ) def test_an_entry_is_in_the_math_where_the_objective_or_a_constraint_inlines_it(patch, in_math): """`in_math` is usage, not shape: one affine body is in the math when a row inlines it, however indirectly, and a reported quantity when none does.""" - program = to_spec(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)).program + program = to_spec(varied(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)).program assert program.expressions['spend'].in_math is in_math def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): """The whole chain is in the math, not only the entry a row names: the constraint inlines `twice`, and `twice` inlines `spend`.""" program = to_spec( - override( + varied( TINY, expressions={'spend': 'sum(p * cost, over=g)', 'twice': 'spend * 2'}, **{'constraints.c.expression': 'twice >= 1'}, @@ -1076,7 +1076,7 @@ def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): """A formal shadows the entry inside the template, so the row inlines the argument, not the same-named entry.""" program = to_spec( - override( + varied( TINY, expressions={'spend': 'sum(p * cost, over=g)'}, macros={'scaled': {'args': ['spend'], 'template': 'spend * 2'}}, @@ -1090,7 +1090,7 @@ def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): def test_an_entry_that_reads_a_dual_is_a_reported_quantity(): """A dual is read after the solve, so an entry calling one is never in the math: it lowers to a Dual leaf and stays reported.""" - program = to_spec(override(TINY, expressions={'shadow_price': 'dual(c)'})).program + program = to_spec(varied(TINY, expressions={'shadow_price': 'dual(c)'})).program declaration = program.expressions['shadow_price'] assert declaration.in_math is False, 'the entry reading a dual is reported, never in the math' assert isinstance(declaration.expression, Dual), 'and it lowers to a Dual leaf' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 8815ac37..eed8ecde 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -19,7 +19,7 @@ from mathspec.piecewise import expand_piecewise from mathspec.program import Assumption, Variable, assumption_message from mathspec.spec import Curvature -from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of +from tests.fixtures import DISPATCH_MODEL, expanded, raw_of, schema_of, varied #: Larger than a minimal probe on purpose: a curve that exercises adjacency #: binaries and links is not something a smaller one can stand in for. @@ -57,12 +57,12 @@ sense: minimize expression: sum(op_cost, over=snapshot) """ -GATED = override( +GATED = varied( raw_of(NONCONVEX_YAML), **{'variables.u': {'dims': ['snapshot'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, ) #: The convex curve stated as its segment lines, plus a binary the method cannot gate on. -LP = override( +LP = varied( raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', @@ -71,9 +71,9 @@ }, ) #: The ``lp`` curve masked by one of its own values-parameters, so every check a block can carry is on it. -LP_MASKED = override(LP, **{'piecewise.cost_curve.points': 'bp_x'}) +LP_MASKED = varied(LP, **{'piecewise.cost_curve.points': 'bp_x'}) #: Two dims in the frame, so the emitted ``dims`` has an order to get wrong. -TWO_DIM = override( +TWO_DIM = varied( raw_of(NONCONVEX_YAML), **{ 'dimensions.generator': {'dtype': 'str'}, @@ -504,7 +504,7 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): #: ``lp`` bounded the other way: the same curve read as its lower envelope. -LP_CONCAVE = override( +LP_CONCAVE = varied( raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', @@ -512,11 +512,11 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): }, ) #: Both links pinned, so nothing says which way the weights are pushed. -CONVEX = override(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) +CONVEX = varied(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) #: The hull bounded below, which is the same relaxation ``lp`` states as its segment lines. -CONVEX_BOUNDED = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) +CONVEX_BOUNDED = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) #: The hull bounded above, so the binding side is the upper one. -CONVEX_BOUNDED_BELOW = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) +CONVEX_BOUNDED_BELOW = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) #: Named so the completeness check below can read the answers back off them. @@ -575,7 +575,7 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" program = expanded( - override(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), + varied(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), 'piecewise', ).program @@ -643,7 +643,7 @@ def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): with pytest.raises( SchemaError, match="writes assumption 'cost_curve_increasing', which this file already declares" ): - expanded(override(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'}), 'piecewise') + expanded(varied(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'}), 'piecewise') @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 1013ced4..d8391142 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -34,6 +34,8 @@ 'BUILTIN_NAMES', # typesetting 'FORMATS', 'SymbolTable', 'typeset', 'typeset_declaration', 'to_latex', 'to_typst', 'to_markdown', + # the two file-level verbs: peers composed, and patches laid over a base + 'merge', 'override', } ) # fmt: skip diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index e1e910fd..611caf80 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 20, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 23, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' diff --git a/tests/test_several_files_page.py b/tests/test_several_files_page.py new file mode 100644 index 00000000..63a79de6 --- /dev/null +++ b/tests/test_several_files_page.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""`docs/several-files.md` shows what each step prints, and each step prints that. + +The page writes its files as YAML blocks with a title, checks some of them from +a shell, and runs Python blocks one after another. A text block or a rendered +output right after a step is what that step prints; a check with none after it +prints nothing. +""" + +from __future__ import annotations + +import contextlib +import io +import re +import textwrap +from pathlib import Path + +import pytest + +from mathspec import LanguageError +from mathspec.__main__ import main + +PAGE = Path(__file__).resolve().parent.parent / 'docs' / 'several-files.md' +TEXT = PAGE.read_text() + +#: A fenced block at the start of a line, with its language and its title. +FENCE = re.compile(r'^```(\w+)(?: title="([^"]+)")?\n(.*?)^```$', re.MULTILINE | re.DOTALL) +#: A rendered output: an admonition whose indented body is what the step printed. +RENDERED = re.compile(r'^!!! example "Rendered output"\n\n((?: .*\n|\n)+)', re.MULTILINE) + + +def _steps() -> list[tuple[str, str | None, str]]: + """Every fenced block and rendered output on the page, in page order, as kind, title and body.""" + found = [(m.start(), m.group(1), m.group(2), m.group(3)) for m in FENCE.finditer(TEXT)] + found += [ + (m.start(), 'rendered', None, textwrap.dedent(m.group(1)).strip() + '\n') for m in RENDERED.finditer(TEXT) + ] + return [(kind, title, body) for _, kind, title, body in sorted(found)] + + +STEPS = _steps() +FILES = {title: body for kind, title, body in STEPS if kind == 'yaml' and title} + + +@pytest.fixture +def folder(tmp_path, monkeypatch): + for name, body in FILES.items(): + (tmp_path / name).write_text(body) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _output_after(index: int) -> str | None: + """What the page says step *index* prints: the text or rendered output right after it, or None.""" + if index + 1 < len(STEPS) and STEPS[index + 1][0] in ('text', 'rendered'): + return STEPS[index + 1][2] + return None + + +def test_the_page_writes_the_files_it_merges(): + assert sorted(FILES) == ['emissions.yaml', 'generators.yaml', 'imports.yaml', 'loads.yaml', 'network.yaml'] + + +@pytest.mark.parametrize( + 'index', [i for i, (kind, _, body) in enumerate(STEPS) if kind == 'bash' and 'mathspec check' in body] +) +def test_a_check_prints_what_the_page_shows(folder, index): + (argv,) = [line.split()[4:] for line in STEPS[index][2].splitlines() if 'mathspec check' in line] + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + status = main(['check', *argv]) + assert status == 0, 'every check on the page accepts its file' + assert printed.getvalue().strip() == (_output_after(index) or '').strip() + + +def test_every_python_step_prints_what_the_page_shows(folder): + """The steps share one namespace, as a reader running them in order does.""" + namespace: dict[str, object] = {} + ran = 0 + for index, (kind, _, body) in enumerate(STEPS): + if kind != 'python': + continue + expected = _output_after(index) + printed = io.StringIO() + try: + with contextlib.redirect_stdout(printed): + exec(body, namespace) + except LanguageError as e: + assert expected is not None and str(e).strip() == expected.strip(), f'step {index} refused otherwise' + else: + assert printed.getvalue().strip() == (expected or '').strip(), f'step {index} printed otherwise' + ran += 1 + assert ran == 5, 'every Python block on the page ran' diff --git a/tests/test_sos.py b/tests/test_sos.py index 32bc6c55..9da45d48 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -14,10 +14,10 @@ import pytest from mathspec.errors import SchemaError -from tests.fixtures import SMALL_MODEL, expanded, override, schema_of +from tests.fixtures import SMALL_MODEL, expanded, schema_of, varied #: A set over a bounded member, which is the smallest model `expand('sos')` acts on. -PICKED = override( +PICKED = varied( SMALL_MODEL, **{ 'parameters.floor': {'dims': ['g']}, @@ -65,7 +65,7 @@ def test_an_unpicked_member_is_held_at_zero_from_the_sides_its_bounds_state(boun """A row multiplies by a bound rather than reading it, so a parameter needs no load-time knowledge of its value; and `x >= 0 * seg` is what the variable's own bound already says, so the second row is written only where it says more.""" - schema = schema_of(override(PICKED, **{'variables.p.bounds': bounds})) + schema = schema_of(varied(PICKED, **{'variables.p.bounds': bounds})) expanded = schema.expand('sos') written = {name: c.expression for name, c in expanded.constraints.items() if name.startswith('pick_nonzero')} @@ -78,19 +78,19 @@ def test_a_set_carries_no_coefficient_of_its_own(): a solver taking the set natively ignored it either way. So the coefficient is the member's own bound and nothing else, and the key is not in the language.""" with pytest.raises(SchemaError, match="unknown key 'bound' in a sos declaration"): - schema_of(override(PICKED, **{'sos.pick.bound': 500})) + schema_of(varied(PICKED, **{'sos.pick.bound': 500})) def test_a_coefficient_of_one_is_left_out_of_the_row_rather_than_printed(): """A binary carries no bounds block, and its upper bound is 1 all the same — which multiplies nothing.""" - schema = schema_of(override(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) + schema = schema_of(varied(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= (pick_seg)' def test_the_emitted_binary_carries_the_members_own_mask(): """A member that does not exist is not in the set, so its binary is not there either.""" - schema = schema_of(override(PICKED, **{'variables.p.where': 'flag'})) + schema = schema_of(varied(PICKED, **{'variables.p.where': 'flag'})) assert schema.expand('sos').variables['pick_seg'].where == 'flag' @@ -104,7 +104,7 @@ def test_a_set_emits_no_parameter_so_the_same_sources_bind_both(): def test_the_adjacency_method_is_the_sos2_curve_with_its_set_written_out(): """The one spelling of the binaries, so the two methods cannot drift apart.""" sos2 = schema_of(CURVE).expand().program - adjacency = expanded(override(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}), 'piecewise').program + adjacency = expanded(varied(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}), 'piecewise').program assert sos2.variables == adjacency.variables assert sos2.constraints == adjacency.constraints diff --git a/tests/test_validation.py b/tests/test_validation.py index 0e8f46b8..b7a625ef 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -18,20 +18,20 @@ from mathspec.resolution import Namespace from mathspec.typesetting import to_markdown from mathspec.validation import to_spec -from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, SMALL_MODEL, override, where_of +from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, SMALL_MODEL, varied, where_of if TYPE_CHECKING: from mathspec.spec import Spec def _schema(**patch) -> Spec: - return to_spec(override(SMALL_MODEL, **patch)) + return to_spec(varied(SMALL_MODEL, **patch)) def _refusal(model: dict[str, Any] = SMALL_MODEL, **patch: Any) -> str: """The message `to_spec` refuses *model* patched with — and it has to refuse.""" with pytest.raises(LanguageError) as caught: - to_spec(override(model, **patch)) + to_spec(varied(model, **patch)) return str(caught.value) @@ -193,7 +193,7 @@ def test_an_unreferenced_nonlinear_entry_loads_and_is_reported(self): definition like any other — rather than degree-checking a declaration nothing consumes. """ - model = override(SMALL_MODEL, expressions={'lcoe': 'c / sum(p)'}) + model = varied(SMALL_MODEL, expressions={'lcoe': 'c / sum(p)'}) assert to_spec(model).program.expressions['lcoe'].in_math is False, ( 'the unread nonlinear body loads rather than being refused, and nothing in the math reads it' ) @@ -224,7 +224,7 @@ def _kwarg_model(expression: str, dims: list[str] | None = None) -> dict[str, An class TestDual: """`dual(c)`: a primitive legal only in an entry the math never reads, its argument a constraint name resolved against constraints alone.""" - BASE = override(SMALL_MODEL, **{'constraints.lim': {'dims': ['g'], 'expression': 'p <= c'}}) + BASE = varied(SMALL_MODEL, **{'constraints.lim': {'dims': ['g'], 'expression': 'p <= c'}}) @pytest.mark.parametrize( ('patch', 'fragments'), @@ -284,13 +284,13 @@ class TestDual: ) def test_a_dual_out_of_place_is_refused(self, patch, fragments): with pytest.raises(LanguageError) as exc: - to_spec(override(self.BASE, **patch)) + to_spec(varied(self.BASE, **patch)) for fragment in fragments: assert fragment in str(exc.value) def test_a_dual_loads_in_an_expressions_entry(self): """The one place it is legal: an ``expressions:`` entry naming a declared constraint, which nothing in the math reads.""" - assert to_spec(override(self.BASE, expressions={'price': 'dual(lim)'})).expressions['price'] + assert to_spec(varied(self.BASE, expressions={'price': 'dual(lim)'})).expressions['price'] class TestDimensionKwargs: @@ -2067,7 +2067,7 @@ def test_an_expression_too_deep_to_walk_fails_as_a_language_error(patch, nests): nothing naming the file, the declaration, or what to write instead. """ with pytest.raises(LanguageError, match='past the 100 levels'): - to_spec(override(DISPATCH_MODEL, **patch)) + to_spec(varied(DISPATCH_MODEL, **patch)) def _chain(n: int, *, deepest_first: bool) -> dict[str, str]: @@ -2089,24 +2089,22 @@ def test_a_chain_of_named_expressions_is_held_to_the_resolved_depth_and_costs_no """ chain = _chain(150, deepest_first=deepest_first) constraint = {'dims': ['snapshot'], 'expression': 'sum(p, over=generator) <= e149'} - spec = to_spec(override(DISPATCH_MODEL, expressions=chain, **{'constraints.c': constraint})) + spec = to_spec(varied(DISPATCH_MODEL, expressions=chain, **{'constraints.c': constraint})) to_markdown(spec.program and spec) with pytest.raises(LanguageError, match='nests 301 deep with every named expression it reads written in') as caught: - to_spec(override(DISPATCH_MODEL, expressions=_chain(151, deepest_first=deepest_first))) + to_spec(varied(DISPATCH_MODEL, expressions=_chain(151, deepest_first=deepest_first))) assert 'past the 300 levels' in str(caught.value) assert "Named expression 'e150'" in str(caught.value), 'refused at the first entry past the depth, by name' with pytest.raises(LanguageError, match='past the 300 levels'): - to_spec(override(DISPATCH_MODEL, expressions=_chain(400, deepest_first=deepest_first))) + to_spec(varied(DISPATCH_MODEL, expressions=_chain(400, deepest_first=deepest_first))) def test_a_name_may_open_with_an_underscore(): """`expressions.md` said a name opens with a letter while the schema and the grammar both admitted `_`, so the page refused what the language accepts.""" schema = to_spec( - override( - DISPATCH_MODEL, **{'parameters._reserve': {'dims': ['generator']}, 'variables.p.where': '_reserve > 0'} - ) + varied(DISPATCH_MODEL, **{'parameters._reserve': {'dims': ['generator']}, 'variables.p.where': '_reserve > 0'}) ) assert '_reserve' in schema.parameters, 'a leading underscore is a name, as NAME and the schema both say' @@ -2139,7 +2137,7 @@ def record(*args, **kwargs): monkeypatch.setattr(module, door.__name__, recorded(door)) spec = to_spec( - override( + varied( DISPATCH_MODEL, **{ 'variables.p.where': 'p_max > 0', @@ -2189,7 +2187,7 @@ def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_n """An entry nothing read loaded and failed only when printed, and one a constraint read was refused under the constraint's name. The program reads an entry's frame off its body at load, so the fault is the entry's, wherever it is read.""" - model = override(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) + model = varied(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) with pytest.raises(DimensionError, match=r"^Named expression 'bad': sum\(over=g\)"): to_spec(model) @@ -2203,7 +2201,7 @@ def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_n ) def test_an_open_bound_is_null_in_the_file_and_in_the_program(upper): """`upper: null` was refused, though every other field a file may leave open takes `null`.""" - spec = to_spec(override(DISPATCH_MODEL, **{'variables.p.bounds': {'lower': 0, **upper}})) + spec = to_spec(varied(DISPATCH_MODEL, **{'variables.p.bounds': {'lower': 0, **upper}})) assert spec.variables['p'].bounds.upper is None assert spec.program.variables['p'].upper is None, 'the program says the side is open rather than infinite' assert spec.to_dict()['variables']['p']['bounds'] == {'lower': 0}, 'an open bound is not written back out' diff --git a/tests/typesetting/test_cases.py b/tests/typesetting/test_cases.py index 9c89cf3d..d37a09be 100644 --- a/tests/typesetting/test_cases.py +++ b/tests/typesetting/test_cases.py @@ -14,7 +14,7 @@ from mathspec import to_latex, to_spec, typeset from mathspec.typesetting.symbols import chosen_expressions from tests.fixtures import DISPATCH_MODEL as DISPATCH -from tests.fixtures import override +from tests.fixtures import varied from tests.typesetting.fixtures import EVERY_FORMAT if TYPE_CHECKING: @@ -30,7 +30,7 @@ } #: The dispatch model, with a quantity defined by region and a constraint using it. -CASED = override( +CASED = varied( DISPATCH, **{ 'expressions.headroom': BY_REGION, @@ -40,7 +40,7 @@ #: One cased expression reached only through another's case. `opening_cost` has #: no variable of its own — its route to one runs through `headroom`. -_NESTED = override( +_NESTED = varied( CASED, **{ 'expressions.headroom.cases.opening.expression': 'p', @@ -82,7 +82,7 @@ def test_the_last_arm_prints_as_the_fallback_rather_than_a_condition(name: Forma @EVERY_FORMAT def test_a_declared_definition_prints_whether_or_not_a_row_names_it(name: FormatName, fmt: Format): """The rule a variable's domain follows: the file declared it, so it prints.""" - unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + unused = varied(CASED, **{'constraints.spare.expression': 'p <= p_max'}) rendered = typeset(unused, name, legend=False) assert rendered.count(fmt.subscript(fmt.upright('headroom'), ['t', 'g'])) == 1, 'the definition, and no use' assert 'Definitions' in rendered @@ -110,7 +110,7 @@ def test_a_cased_expression_is_chosen_when_a_value_reaching_it_is( returns, and one case holding a variable is enough. The `otherwise:` is a value of the quantity like any case's, so a walk reading only the cases prints a solved quantity upright.""" - rendered = typeset(override(CASED, **patch), name, legend=False) + rendered = typeset(varied(CASED, **patch), name, legend=False) italic, upright = (fmt.subscript(face('headroom'), ['t', 'g']) for face in (fmt.italic, fmt.upright)) assert (italic in rendered) is chosen, 'the quantity is chosen exactly when a value reaching it holds a variable' assert (upright in rendered) is not chosen, 'and given otherwise, however its regions are chosen' @@ -143,7 +143,7 @@ def test_the_table_may_rename_a_named_expression_cased_or_plain(): tex = to_latex(CASED, symbols={'notation': 'latex', 'names': {'headroom': r'\bar h'}}, legend=False) assert r'\bar h_{t,g}' in tex - plain = override(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) + plain = varied(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) tex = to_latex(plain, symbols={'notation': 'latex', 'names': {'supply': 's'}}, legend=False) assert 's_{t} & =' in tex, 'the definition prints under the spelling the table gave' @@ -157,7 +157,7 @@ def test_the_definitions_print_in_declaration_order(): carrying two of them would churn on every regeneration. """ declared = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] - tex = to_latex(override(CASED, **{f'expressions.{n}': BY_REGION for n in declared}), legend=False) + tex = to_latex(varied(CASED, **{f'expressions.{n}': BY_REGION for n in declared}), legend=False) section = tex[tex.index('Definitions') : tex.index('Variable domains')] labels = re.findall(r'^\\text\{(\w+)\} &&', section, flags=re.MULTILINE) assert labels == ['headroom', *declared], "declaration order, the file's own" diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index 3366537b..321fd3fa 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -6,13 +6,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest from mathspec import LanguageError, SchemaError, typeset_declaration from tests.fixtures import DISPATCH_MODEL as DISPATCH -from tests.fixtures import override +from tests.fixtures import varied from tests.typesetting.fixtures import EVERY_FORMAT from tests.typesetting.test_cases import CASED @@ -22,7 +22,7 @@ #: A variable-carrying reduction, a scalar reduction, a data-only body, and a #: constraint reading the first — the shapes a line has to read. -PLAIN = override( +PLAIN = varied( DISPATCH, **{ 'expressions.spend': 'sum(p * cost, over=generator)', @@ -114,7 +114,7 @@ def test_a_symbol_table_renames_an_expression_either_way(): def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_the_document(): """On its own, `double_spend` is complete only with `spend` substituted; in the document both are defined, each once, so a use prints the symbol.""" - model = override(PLAIN, **{'expressions.double_spend': 'spend * 2'}) + model = varied(PLAIN, **{'expressions.double_spend': 'spend * 2'}) assert typeset_declaration(model, 'double_spend', 'latex') == ( r'\mathit{double\_spend}_{t} = \left( \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \right) ' r'\cdot 2 \qquad \forall\, t \in \mathcal{T}' @@ -124,25 +124,42 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ ) +#: A column and a row family this file reads, each named by something that prints. +GIVEN = varied( + PLAIN, + **{ + 'given.variables.flow': {'dims': ['snapshot']}, + 'given.constraints.clearing': {'dims': ['snapshot']}, + 'expressions.price': 'dual(clearing)', + 'expressions.drawn': 'flow * 2', + }, +) + + @pytest.mark.parametrize( - ('name', 'match'), + ('model', 'name', 'match'), [ pytest.param( + PLAIN, 'spent', r"'spent' is not a named expression, constraint, assumption, curve or variable.*spend", id='a-near-miss', ), - pytest.param('objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), + pytest.param(PLAIN, 'objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), + pytest.param(GIVEN, 'flow', r"'flow' is a given variable.*no line of its own.*legend", id='a-given-variable'), + pytest.param( + GIVEN, 'clearing', r"'clearing' is a given constraint.*no line of its own.*legend", id='a-given-constraint' + ), ], ) -def test_a_name_declared_as_none_of_the_four_is_refused(name: str, match: str): +def test_a_name_that_prints_no_line_of_its_own_is_refused(model: dict[str, Any], name: str, match: str): with pytest.raises(SchemaError, match=match): - typeset_declaration(PLAIN, name, 'latex') + typeset_declaration(model, name, 'latex') def test_a_name_shared_by_a_constraint_and_a_variable_is_refused_rather_than_guessed(): """Constraints sit outside the flat namespace, so the model admits the pair; one line prints one of them.""" - model = override(PLAIN, **{'constraints.p': {'dims': ['snapshot', 'generator'], 'expression': 'p <= 1'}}) + model = varied(PLAIN, **{'constraints.p': {'dims': ['snapshot', 'generator'], 'expression': 'p <= 1'}}) with pytest.raises(SchemaError, match="'p' is declared twice, as constraint and as variable"): typeset_declaration(model, 'p', 'latex') @@ -153,6 +170,6 @@ def test_a_format_nobody_spells_is_refused(): def test_an_invalid_model_is_refused_before_anything_renders(): - broken = override(PLAIN, **{'expressions.spend': 'p * nonexistent'}) + broken = varied(PLAIN, **{'expressions.spend': 'p * nonexistent'}) with pytest.raises(LanguageError): typeset_declaration(broken, 'spend', 'latex') diff --git a/tests/typesetting/test_formats.py b/tests/typesetting/test_formats.py index e94b34fe..0a7cd4bc 100644 --- a/tests/typesetting/test_formats.py +++ b/tests/typesetting/test_formats.py @@ -14,7 +14,7 @@ from mathspec import to_spec, typeset_declaration from mathspec.typesetting import FORMATS, to_latex, to_markdown, to_typst, typeset from mathspec.typesetting.format import OPERATOR_NAMES -from tests.fixtures import DISPATCH_MODEL, override +from tests.fixtures import DISPATCH_MODEL, varied from tests.typesetting import golden from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS @@ -116,7 +116,7 @@ def test_every_typst_operator_compiles(typst, tmp_path: Path): def test_the_model_description_opens_the_document(name: FormatName, fmt: Format, options: dict): """What the file says it is, printed before anything it declares — and printed with `legend=False` too, since it is not a symbol table.""" - described = override(DISPATCH_MODEL, description='least-cost dispatch of a generator fleet') + described = varied(DISPATCH_MODEL, description='least-cost dispatch of a generator fleet') out = typeset(described, name, **options) assert 'least-cost dispatch of a generator fleet' in out assert out.index('least-cost dispatch') < out.index(fmt.operators['minimize']), 'it opens the document' @@ -171,7 +171,7 @@ def test_a_description_sets_as_text_rather_than_as_markup(notation: str, positio one. """ where = 'description' if position == 'file' else 'parameters.load.description' - out = typeset(override(DISPATCH_MODEL, **{where: SPECIALS}), notation) + out = typeset(varied(DISPATCH_MODEL, **{where: SPECIALS}), notation) for expected in ESCAPED[notation]: assert expected in out, 'each special is escaped, and a character the notation reads as text is left alone' assert SPECIALS not in out, 'the raw prose reached the document unescaped' @@ -197,7 +197,7 @@ def test_a_backticked_name_in_a_description_sets_in_monospace(notation: str): span is part of the language's reading of prose rather than a Markdown habit that two formats printed as characters (#401). """ - out = typeset(override(DISPATCH_MODEL, **{'parameters.load.description': SPANNED}), notation) + out = typeset(varied(DISPATCH_MODEL, **{'parameters.load.description': SPANNED}), notation) assert SPANNED_AS[notation] in out, ( 'the span is monospace, its underscore escaped where the format needs it, and the lone backtick a character' ) @@ -207,7 +207,7 @@ def test_a_backticked_name_in_a_description_sets_in_monospace(notation: str): 'model', [ pytest.param(golden.MODEL, id='the-golden-model'), - pytest.param(override(DISPATCH_MODEL, description=SPECIALS), id='every-special'), + pytest.param(varied(DISPATCH_MODEL, description=SPECIALS), id='every-special'), ], ) def test_a_description_of_every_special_compiles(typst, tmp_path: Path, model): @@ -223,7 +223,7 @@ def test_a_description_of_every_special_compiles(typst, tmp_path: Path, model): def test_typst_prose_escapes_what_typst_reads_as_markup(typst, tmp_path: Path): - described = override(DISPATCH_MODEL, description='- a list? a // comment [a link] and = a heading') + described = varied(DISPATCH_MODEL, description='- a list? a // comment [a link] and = a heading') typ = to_typst(described, standalone=True) assert r'\- a list? a \/\/ comment \[a link\] and = a heading' in typ, ( 'a leading list marker, a comment and a link are escaped, and an inline `=` is no heading' @@ -234,7 +234,7 @@ def test_typst_prose_escapes_what_typst_reads_as_markup(typst, tmp_path: Path): def test_markdown_glossary_cells_survive_a_pipe_and_a_newline(): - described = override(DISPATCH_MODEL, **{'parameters.load.description': 'a | b\nc'}) + described = varied(DISPATCH_MODEL, **{'parameters.load.description': 'a | b\nc'}) md = to_markdown(described) assert r'| `load` over $`\mathcal{T}`$ — a \| b c |' in md, ( 'the pipe is escaped and the newline folded, so the cell stays one cell' diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 6658fbc6..2caa11fa 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -13,7 +13,7 @@ from mathspec.errors import SchemaError from mathspec.typesetting import SymbolTable, to_latex, to_markdown, to_typst, typeset from mathspec.validation import to_spec -from tests.fixtures import DISPATCH_MODEL, override +from tests.fixtures import DISPATCH_MODEL, varied from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS if TYPE_CHECKING: @@ -22,7 +22,7 @@ from mathspec.typesetting.format import Format -WITH_MARGINAL_COST = override( +WITH_MARGINAL_COST = varied( DISPATCH_MODEL, **{'parameters.marginal_cost': {'dims': ['generator']}, 'objective.expression': 'sum(p * marginal_cost)'}, ) @@ -57,7 +57,7 @@ def test_the_table_prints_verbatim_and_the_rest_is_still_derived(render, symbols assert fragment in out -DESCRIBED = override( +DESCRIBED = varied( DISPATCH_MODEL, **{ 'dimensions.generator.description': 'dispatchable units', @@ -97,7 +97,7 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam #: The dispatch model with a curve on it, so one model has two readings and one #: table has to spell both. -CURVED = override( +CURVED = varied( DISPATCH_MODEL, **{ 'dimensions.bp': {'dtype': 'int'}, @@ -151,7 +151,7 @@ def test_a_table_spells_the_names_the_expansion_declares_and_no_other(patch): """A name any method could write counted as declared, so a table naming the chord of a curve that has none, or the curve's own set, was ignored rather than refused.""" - spec = to_spec(override(CURVED, **patch)) + spec = to_spec(varied(CURVED, **patch)) written = _names(spec.expand()) - _names(spec) reserved = { 'curve', diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 26ce004d..c9d98895 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -16,7 +16,7 @@ from mathspec.typesetting.format import OPERATOR_NAMES from mathspec.typesetting.symbols import _derive_name_symbol, chosen_expressions, symbols_for from mathspec.validation import to_spec -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, OPERATOR_PROBES, override +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, OPERATOR_PROBES, varied from tests.typesetting import golden from tests.typesetting.fixtures import EVERY_FORMAT, LATEX @@ -55,7 +55,7 @@ def test_a_dimension_index_never_steals_a_letter_a_variable_owns(name: FormatNam @EVERY_FORMAT def test_a_where_lands_on_the_quantifier_not_in_the_equation(name: FormatName, fmt: Format): """A mask is row absence, so it belongs to the ∀ that names the rows.""" - model = override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}) + model = varied(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}) text = typeset(model, name, legend=False) forall, such_that = fmt.operators['forall'], fmt.operators['such_that'] masked = [line for line in text.splitlines() if such_that in line] @@ -252,7 +252,7 @@ def test_translations_that_disagree_at_the_edge_do_not_merge(name: FormatName, f @EVERY_FORMAT def test_a_negation_under_a_plus_is_the_subtraction_it_means(name: FormatName, fmt: Format): """`a + -b` is a spelling nobody uses, and the walk was printing it.""" - model = override(DISPATCH_MODEL, **{'objective.expression': 'sum(p) + -sum(p)'}) + model = varied(DISPATCH_MODEL, **{'objective.expression': 'sum(p) + -sum(p)'}) text = typeset(model, name) assert f'{fmt.operators["plus"]} {fmt.operators["minus"]}' not in text, 'a plus over a negation is a subtraction' assert fmt.operators['minus'] in text, 'the subtraction it folded into should still print' @@ -266,10 +266,10 @@ def test_a_mask_that_is_only_true_prints_no_condition(name: FormatName, fmt: For Nested it printed — `\\top \\wedge x` — while the program lowered the same mask to `x`: two readers of one file disagreeing about what it says. """ - always = override(DISPATCH_MODEL, **{'constraints.balance.where': 'True'}) + always = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'True'}) assert typeset(always, name) == typeset(DISPATCH_MODEL, name), 'a mask every row passes is no mask at all' - nested = override(DISPATCH_MODEL, **{'constraints.balance.where': 'True AND load > 0'}) - plain = override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'}) + nested = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'True AND load > 0'}) + plain = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'}) assert typeset(nested, name) == typeset(plain, name), 'a literal under a connective is folded before it prints' @@ -383,7 +383,7 @@ def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(name: dash in two of the three outputs and as three hyphens in the one whose whole promise is that it renders where it lands. """ - described = override(DISPATCH_MODEL, **{'parameters.cost.description': 'marginal cost'}) + described = varied(DISPATCH_MODEL, **{'parameters.cost.description': 'marginal cost'}) text = typeset(described, name) assert f'{fmt.dash} marginal cost' in text if fmt is FORMATS['markdown']: @@ -395,7 +395,7 @@ def test_a_named_expression_prints_once_as_a_definition_and_by_symbol_where_used """The file names the quantity, so the page does: a use prints the symbol and the body prints once under Definitions. A macro is sugar with no identity of its own, so it is expanded away either way.""" - model = override( + model = varied( DISPATCH_MODEL, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.balance.expression': 'supply == load'}, ) @@ -407,7 +407,7 @@ def test_a_named_expression_prints_once_as_a_definition_and_by_symbol_where_used @EVERY_FORMAT def test_inlining_substitutes_a_named_expression_where_it_is_used(name: FormatName, fmt: Format): """What prints then is the math a backend builds, not the name it was spelled with.""" - model = override( + model = varied( DISPATCH_MODEL, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.balance.expression': 'supply == load'}, ) @@ -418,7 +418,7 @@ def test_inlining_substitutes_a_named_expression_where_it_is_used(name: FormatNa @EVERY_FORMAT def test_an_invalid_model_is_refused_before_anything_renders(name: FormatName, fmt: Format): - broken = override(DISPATCH_MODEL, **{'objective.expression': 'p * nonexistent'}) + broken = varied(DISPATCH_MODEL, **{'objective.expression': 'p * nonexistent'}) with pytest.raises(LanguageError): typeset(broken, name) @@ -428,7 +428,7 @@ def test_inlining_keeps_the_definition_of_an_entry_the_math_never_reads(name: Fo """Substitution has nowhere to put it: nothing in the objective or a constraint names it, so dropping its definition would drop the quantity from the page entirely.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'expressions.supply': 'sum(p, over=generator)', @@ -446,7 +446,7 @@ def test_a_dual_prints_the_constraint_symbol_not_a_same_named_variable(name: For """`dual(c)` subscripts λ from a map of its own, so a variable sharing the constraint's name — a legal collision, constraints sit outside the flat namespace (#74) — cannot lend the dual its italic letter.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'variables.balance': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, @@ -467,7 +467,7 @@ def test_an_entry_reading_a_dual_prints_italic(name: FormatName, fmt: Format): """Upright is what the model is given, and a shadow price is not: no data hands it over, the solve settles it — the same reason a variable is italic, though a dual carries no variable for `carries_variable` to find.""" - model = override(DISPATCH_MODEL, **{'expressions.mp': 'dual(balance)'}) + model = varied(DISPATCH_MODEL, **{'expressions.mp': 'dual(balance)'}) assert fmt.subscript(fmt.italic('mp'), ['t']) in typeset(model, name, legend=False), ( 'the entry is read off the solution, so its own symbol is italic' ) @@ -509,14 +509,14 @@ def test_a_given_quantity_is_upright(name: str, expected: str): r"""Upright is what the data supplies, and it admits no exception — not for a single letter, and not for a Greek name, where an italic `\eta` that might be either is worse than an upright `\mathrm{eta}` that is one.""" - assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, given=True) == expected + assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, upright=True) == expected @EVERY_FORMAT def test_a_name_that_is_a_greek_letter_prints_as_the_letter(name: FormatName, fmt: Format): """A variable called `theta` set as the italic word *theta* is the one derived symbol no paper would accept.""" - model = override(DISPATCH_MODEL, **{'variables.theta': {'dims': ['snapshot']}}) + model = varied(DISPATCH_MODEL, **{'variables.theta': {'dims': ['snapshot']}}) assert fmt.greek('theta') in typeset(model, name) @@ -572,7 +572,7 @@ def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(name: FormatName, fmt: whether some unrelated dimension happened to share its prefix: declare a dimension named `tech` and `tech_cap` silently re-rendered. """ - model = override( + model = varied( DISPATCH_MODEL, **{'dimensions.zone': {'dtype': 'str'}, 'parameters.zone_cap': {'dims': ['zone']}}, ) @@ -628,16 +628,14 @@ def test_the_objective_shows_the_summations_the_file_wrote(name: FormatName, fmt @EVERY_FORMAT def test_two_sums_of_the_same_dims_stay_two_summations(name: FormatName, fmt: Format): """The file's structure survives to the page, even where it repeats itself.""" - text = typeset(override(MIXED, **{'objective.expression': 'sum(p * cost) + sum(p * cost)'}), name, legend=False) + text = typeset(varied(MIXED, **{'objective.expression': 'sum(p * cost) + sum(p * cost)'}), name, legend=False) assert summations(text, fmt) == 2, 'two written sums are two summations' @EVERY_FORMAT def test_a_subtracted_summation_keeps_the_sign_outside_it(name: FormatName, fmt: Format): """The sign is applied to the whole reduction, and the bracket says so.""" - text = typeset( - override(MIXED, **{'objective.expression': 'sum(p * cost) - sum(p_nom * capex)'}), name, legend=False - ) + text = typeset(varied(MIXED, **{'objective.expression': 'sum(p * cost) - sum(p_nom * capex)'}), name, legend=False) opener = fmt.parenthesise('BODY').split('BODY')[0] + over_generators(fmt) assert f'{fmt.operators["minus"]} {opener}' in text @@ -683,7 +681,7 @@ def test_every_operator_probe_renders(path, name: FormatName, fmt: Format): def _grouped(dims: list[str], expression: str) -> str: """The constraint `c` over *dims*, as the one line of LaTeX it prints.""" - model = override(UNREAD, **{'constraints.c': {'dims': dims, 'expression': expression}}) + model = varied(UNREAD, **{'constraints.c': {'dims': dims, 'expression': expression}}) return next(line for line in to_latex(model, legend=False).splitlines() if line.startswith(r'\text{c}')) @@ -733,7 +731,7 @@ def test_a_value_column_the_call_consumes_is_a_condition_like_a_produced_one(): def _row(expression: str, where: str | None = None, **patch: object) -> str: - model = override( + model = varied( BUSES, **{'constraints.k': {'dims': ['snapshot', 'generator'], 'expression': expression, 'where': where}}, **patch, @@ -850,7 +848,7 @@ def test_a_string_value_in_a_where_prints_as_a_quoted_label(name: FormatName, fm @EVERY_FORMAT def test_a_comparison_of_expressions_prints_as_the_arithmetic_it_is(name: FormatName, fmt: Format): """`cost <= p_max / 2` on a quantifier renders each side as an expression, around the relation.""" - model = override(DISPATCH_MODEL, **{'variables.p.where': 'cost <= p_max / 2'}) + model = varied(DISPATCH_MODEL, **{'variables.p.where': 'cost <= p_max / 2'}) text = typeset(model, name, legend=False) p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) cost = fmt.subscript(fmt.upright('cost'), ['g']) @@ -860,7 +858,7 @@ def test_a_comparison_of_expressions_prints_as_the_arithmetic_it_is(name: Format @EVERY_FORMAT def test_a_count_prints_as_the_size_of_the_set_the_predicate_admits(name: FormatName, fmt: Format): """A count is a cardinality over a set by comprehension, which is how a paper writes one.""" - model = override(DISPATCH_MODEL, **{'constraints.balance.where': 'count(p_max > 0, over=generator) >= 2'}) + model = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'count(p_max > 0, over=generator) >= 2'}) text = typeset(model, name, legend=False) p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) counted = fmt.set_of( @@ -873,7 +871,7 @@ def test_a_count_prints_as_the_size_of_the_set_the_predicate_admits(name: Format @EVERY_FORMAT def test_a_translated_predicate_prints_at_the_index_it_reads(name: FormatName, fmt: Format): """The translation shows at the leaf, as it does for arithmetic — it emits no operator of its own.""" - model = override( + model = varied( DISPATCH_MODEL, **{'constraints.balance.where': 'load AND NOT shift(load, along=snapshot, offset=1)'} ) text = typeset(model, name, legend=False) @@ -884,7 +882,7 @@ def test_a_translated_predicate_prints_at_the_index_it_reads(name: FormatName, f def test_a_count_along_a_dim_the_frame_carries_takes_a_primed_dummy(): """The set's index would otherwise shadow the frame's, and the two stand for different coordinates.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'constraints.balance': { @@ -901,7 +899,7 @@ def test_a_count_along_a_dim_the_frame_carries_takes_a_primed_dummy(): @EVERY_FORMAT def test_an_assumption_prints_under_its_own_heading(name: FormatName, fmt: Format): """What the data is held to prints with the math, because a reader checking it reads the same document.""" - model = override(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) + model = varied(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) text = typeset(model, name, legend=False) section = text[text.index('Assumptions') :] assert fmt.subscript(fmt.upright('cost'), ['g']) in section @@ -931,7 +929,7 @@ def test_a_curve_prints_what_its_method_assumes_of_the_breakpoints(name: FormatN def test_an_assumption_is_a_declaration_a_line_may_be_asked_for(): """`typeset_declaration` prints one line for a name; an assumption is now one of the names it takes.""" - model = override(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) + model = varied(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) assert typeset_declaration(model, 'costs_are_positive', 'latex') == ( r'\mathrm{cost}_{g} > 0 \qquad \forall\, g \in \mathcal{G}' ) @@ -1007,12 +1005,12 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i ) def test_a_curve_prints_as_the_curve_it_states(patch: dict[str, Any], expected: str): """The block, not the rows it stands for: `typeset(spec.expand())` prints those.""" - assert expected in typeset_declaration(override(_CURVE, **patch), 'curve', 'latex') + assert expected in typeset_declaration(varied(_CURVE, **patch), 'curve', 'latex') def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion_writes_two_rows_for(): """The one place the walk decides what the weights sum to, which the expansion decides again.""" - spec = to_spec(override(_CURVE, **{'piecewise.curve.activity': 'warm'})) + spec = to_spec(varied(_CURVE, **{'piecewise.curve.activity': 'warm'})) rows = [name for name in spec.expand('piecewise').constraints if name.startswith('curve_convexity')] assert rows == ['curve_convexity', 'curve_convexity_ungated'], ( @@ -1026,7 +1024,7 @@ def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_of(): """Two homes for one union, so the line's quantifier is held to the rows the expansion emits.""" - model = override( + model = varied( _CURVE, **{ 'dimensions.generator': {'dtype': 'str'}, @@ -1056,7 +1054,7 @@ def test_the_expansion_prints_the_rows_the_block_states(): @EVERY_FORMAT def test_a_set_is_labelled_by_the_block_that_declares_it(name: FormatName, fmt: Format): """The line was labelled `` sos``, a name the file never wrote, while every other line carries its key.""" - picked = override( + picked = varied( DISPATCH_MODEL, **{ 'variables.p.bounds': {'lower': 0, 'upper': 10}, diff --git a/tools/_page.py b/tools/_page.py index 46846067..af0eb2d9 100644 --- a/tools/_page.py +++ b/tools/_page.py @@ -9,6 +9,7 @@ import argparse import re import sys +import textwrap from pathlib import Path from typing import TYPE_CHECKING @@ -44,6 +45,11 @@ def inlined(markdown: str) -> str: return FENCE.sub(lambda m: f'$`{" ".join(m[1].splitlines())}`$', markdown) +def tab(title: str, body: str) -> str: + """One tab of a tabbed block: its title, and its body indented into it.""" + return f'=== "{title}"\n\n{textwrap.indent(body, " ")}' + + def without_header(path: Path) -> str: """The file from its first line that is neither blank nor a comment: the licence header is the repository's.""" lines = path.read_text().splitlines() diff --git a/tools/gallery.py b/tools/gallery.py index e03c4b91..08d82e5d 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -17,11 +17,13 @@ import re import textwrap from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from mathspec import to_spec +import yaml + +from mathspec import merge, override, to_spec from mathspec.typesetting import to_markdown -from tools._page import ROOT, sidecar_for, splice, without_header +from tools._page import ROOT, sidecar_for, splice, tab, without_header from tools._page import main as page_main from tools.notation import equations from tools.spec_math import OPERATORS, PROBES, _section, rendered_probe @@ -29,7 +31,14 @@ if TYPE_CHECKING: from pathlib import Path + from mathspec.spec import Spec + PAGES = ROOT / 'docs' / 'examples' +LIBRARY = ROOT / 'examples' / 'library' +#: How `examples/library/` prints: one table for every fragment, the spec +#: they compose and each variant laid over it. `symbols_for` cuts it to what +#: one spec declares, because a table naming anything else is refused. +LIBRARY_SYMBOLS = ROOT / 'examples' / 'symbols' / 'library.yaml' BEGIN, END = '', '' #: Page -> the spec it shows. One spec per page, because a gallery of @@ -37,6 +46,20 @@ MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', 'commitment.md': ROOT / 'examples' / 'commitment.yaml', + 'library/surface.md': LIBRARY / 'surface.yaml', + 'library/generator.md': LIBRARY / 'generator.yaml', + 'library/load.md': LIBRARY / 'load.yaml', +} + +#: Page -> the fragments whose composition it shows, and the patches laid over +#: it. The spec is what `merge` returns, which no file in the tree holds, so +#: the page carries it as YAML beside the math it prints. A patch has no math +#: of its own, so each one prints as the spec it lands on, in a tab of its own. +COMPOSED = { + 'library/composed.md': ( + [LIBRARY / name for name in ('surface.yaml', 'generator.yaml', 'load.yaml')], + {path.stem: path for path in sorted((LIBRARY / 'variants').glob('*.yaml'))}, + ), } #: Page -> the spec it shows one declaration at a time — its YAML, then the @@ -63,6 +86,49 @@ def model_block(path: Path) -> str: return f'```yaml\n{without_header(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' +def symbols_for(model: Spec) -> dict[str, Any]: + """The library's symbol table, cut to the dimensions and names *spec* declares.""" + table = yaml.safe_load(LIBRARY_SYMBOLS.read_text()) + named = {*model.parameters, *model.variables, *model.given.variables, *model.expressions, *model.constraints} + return { + 'notation': table['notation'], + 'dimensions': {name: symbol for name, symbol in table['dimensions'].items() if name in model.dimensions}, + 'names': {name: symbol for name, symbol in table['names'].items() if name in named}, + } + + +def library_block(path: Path) -> str: + """One fragment of the library, then its document in the notation the whole library prints in.""" + model = to_spec(path) + printed = to_markdown(model, symbols=symbols_for(model), numbered=False) + return f'```yaml\n{without_header(path)}\n```\n\n{printed.strip()}' + + +def composed_block(fragments: list[Path], patches: dict[str, Path]) -> str: + """The spec `merge` returns for *fragments* as YAML, then its document as composed and under each patch. + + The composed YAML is generated rather than committed, so the page cannot + show a composition the fragments beside it no longer make. A patch is + refused on its own, so its tab carries the patch file and then the whole + document of the spec it is laid over. + """ + model = merge({path.stem: path for path in fragments}) + tabs = [tab('As composed', to_markdown(model, symbols=symbols_for(model), numbered=False).strip())] + for name, path in patches.items(): + patched = override(model, {name: path}) + tabs.append( + tab( + f'With {name}', + f'```yaml title="variants/{path.name}"\n{without_header(path)}\n```\n\n' + f'{to_markdown(patched, symbols=symbols_for(patched), numbered=False).strip()}', + ) + ) + dumped = yaml.safe_dump( + model.to_dict(), sort_keys=False, default_flow_style=None, allow_unicode=True, width=100 + ).strip() + return f'```yaml\n{dumped}\n```\n\n' + '\n\n'.join(tabs) + + def probe_block() -> str: """Every operator probe: the spec, then the one equation it renders.""" parts = [] @@ -188,6 +254,10 @@ def block(page: str) -> str: return probe_block() if page in DECLARED: return declared_block(DECLARED[page]) + if page in COMPOSED: + return composed_block(*COMPOSED[page]) + if MODELS[page].parent == LIBRARY: + return library_block(MODELS[page]) return model_block(MODELS[page]) @@ -199,7 +269,7 @@ def rendered(page: str, text: str) -> str: def pages() -> list[str]: - return [*MODELS, *DECLARED, 'operators.md'] + return [*MODELS, *COMPOSED, *DECLARED, 'operators.md'] def main(argv: list[str] | None = None) -> int: diff --git a/tools/home_math.py b/tools/home_math.py index 30e791e7..0ecf1ed6 100644 --- a/tools/home_math.py +++ b/tools/home_math.py @@ -16,11 +16,9 @@ from __future__ import annotations -import textwrap - from mathspec import to_spec from mathspec.typesetting import to_latex, to_markdown -from tools._page import ROOT, inlined, sidecar_for, splice, without_header +from tools._page import ROOT, inlined, sidecar_for, splice, tab, without_header from tools._page import main as page_main PAGE = ROOT / 'docs' / 'index.md' @@ -76,11 +74,6 @@ loads.""" -def tab(title: str, body: str) -> str: - """One tab of the block: its title, and its body indented into it.""" - return f'=== "{title}"\n\n{textwrap.indent(body, " ")}' - - def block() -> str: """The three tabs, in the order a reader meets them.""" spec = to_spec(MODEL) diff --git a/tools/render_tex.py b/tools/render_tex.py index 4e5c731a..aae1cfbb 100644 --- a/tools/render_tex.py +++ b/tools/render_tex.py @@ -22,8 +22,10 @@ #: Every spec the repository has; `examples/*.yaml` is not recursive, and a glob that narrows is a gate that stops testing. CORPUS = ('examples/**/*.yaml', 'tests/typesetting/golden/*.yaml') -#: Inside that glob and not specs: the symbol tables `sidecar_for` looks up. -NOT_MODELS = ('examples/symbols',) +#: Inside that glob and not specs: the symbol tables `sidecar_for` looks up, +#: and the patches a library's variants are written as, which `override` lays +#: over a spec rather than anything loading them on their own. +NOT_MODELS = ('examples/symbols', 'examples/library/variants') def models() -> list[Path]: From 0c3fa4783f3b1559a5ff8a84436629643ea23414 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:33:07 +0000 Subject: [PATCH 52/75] Add the changelog link for #732 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d93fc858..d54c853a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) -- feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#PR](https://github.com/energy-models/mathspec/pull/PR)) +- feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) From a5156fff1a664d9c240ac86469294c6bdf84f403 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:54:08 +0000 Subject: [PATCH 53/75] refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 1 + docs/reference/language/declarations.md | 20 +++---- docs/reference/reading.md | 4 +- src/mathspec/composition.py | 2 +- src/mathspec/dimensions.py | 10 ---- src/mathspec/lowering.py | 4 +- src/mathspec/program.py | 3 - src/mathspec/typesetting/legend.py | 7 +-- src/mathspec/validation.py | 12 ---- tests/test_additive.py | 79 +++++++++++++++---------- 10 files changed, 65 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d54c853a..cfc956e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) +- refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 754f78f2..8fa44ec6 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -283,19 +283,19 @@ Each file loads alone: the reader over a sum it does not build, and the contributor over its own term. Other readers state the frame and nothing more. A file may carry the marked entry and declare a term too. Then it reads the -sum so far, which alone is its own term. The term is one `expression:`, and -carries no dimension the entry does not state; both are checked at load. The -entry then folds into the definition, and the typeset legend lists it under -_Definitions_, as a sum other files add terms to. +sum so far, which alone is its own term. The file loads as any other: the +term is an ordinary named expression, and the typeset legend lists it under +_Definitions_ with no note. Only `merge` holds a term to the rules of a sum. [`merge`](../../howto/compose.md#a-library-of-components) sums every term of a marked name, each in parentheses, in fragment-name order, and keeps the marked -entry, so a later merge adds more. The entry's description is the sum's. It -refuses a term written as `cases:`, a term over a dimension the entry does not -state, and a file that declares a term and reads the name without carrying the -marked entry: on its own that file reads its term, and composed it would read -the sum. Two terms of a name no entry marks are refused as a collision. A -marked name no file adds to stays under `given:`. +entry, so a later merge adds more. The entry keeps its description, and the +sum takes the first term's. It refuses a term written as `cases:`, a term over +a dimension the entry does not state, and a file that declares a term and +reads the name without carrying the marked entry: on its own that file reads +its term, and composed it would read the sum. Two terms of a name no entry +marks are refused as a collision. A marked name no file adds to stays under +`given:`. ## `constraints` diff --git a/docs/reference/reading.md b/docs/reference/reading.md index aebf981f..b9a56ba4 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -171,8 +171,8 @@ other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. A given expression with `additive` set is a sum other files add terms to. -Where the file also declares a term of it, the name is a definition with -`additive` set under `program.expressions`, and not a given name. +Where the file also declares a term of it, the name is an ordinary definition +under `program.expressions`, and not a given name. ```python layer = to_spec( diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index f476e448..a45ac1dc 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -304,7 +304,7 @@ def _summed_terms( 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) } said = next((b['description'] for _, b in terms if b.get('description')), None) - if said is not None and not entry.get('description'): + if said is not None: term['description'] = said summed[key] = term return summed diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index dd81de51..1777d12c 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -292,16 +292,6 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) - for ename, sum_entry in schema.given.expressions.items(): - if not (sum_entry.additive and ename in program.expressions): - continue - if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): - raise DimensionError( - f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " - f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " - f'them out of the term.' - ) - for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index e057dd2e..bdd0aebc 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -222,9 +222,7 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description - or (schema.given.expressions[name].description if name in sums else None), - additive=name in sums, + description=schema.expressions[name].description, ) for name, entry in entries.items() }, diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 1f47fd1c..7de4448a 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -715,9 +715,6 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None - #: A sum other files add terms to: the file marks the name so under - #: ``given: expressions:``, and this body is the sum so far. - additive: bool = False @dataclass(frozen=True) diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 65bac5f4..ff0ad745 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -188,12 +188,7 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str ] shown = set(defined) definitions = [ - self._entry( - self.symbols.name[e], - f'{fmt.mono(e)}{self._over(list(block.dims))}' - + (', a sum other files add terms to' if block.additive else ''), - block.description, - ) + self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) for e, block in program.expressions.items() if e in shown ] diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 1553e65e..653f70a5 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -89,7 +89,6 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_bounds(schema), *_piecewise_references(schema), *_given_constraint_collisions(schema), - *_cased_terms(schema), ] @@ -147,17 +146,6 @@ def _given_constraint_collisions(schema: Spec) -> Iterator[str]: ) -def _cased_terms(schema: Spec) -> Iterator[str]: - """A term of a sum is one expression, since the terms are summed as written.""" - for name in sorted(defined_sums(schema)): - if schema.expressions[name].cases: - yield ( - f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " - f'The terms are summed as written: name the cased term as its own expression, and write ' - f'that name as the term.' - ) - - def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ diff --git a/tests/test_additive.py b/tests/test_additive.py index 91c67545..d2975088 100644 --- a/tests/test_additive.py +++ b/tests/test_additive.py @@ -73,7 +73,7 @@ def test_a_reader_marks_the_sum_and_loads_on_its_own(): def test_a_term_is_an_ordinary_named_expression(): program = to_spec(FLEET).program - assert not program.expressions['injection'].additive, 'nothing in a plain contributor says it is a term' + assert 'injection' in program.expressions and not program.given, 'nothing in a plain contributor says it is a term' #: A file that adds a term of its own and reads the sum: it carries both. @@ -87,34 +87,47 @@ def test_a_term_is_an_ordinary_named_expression(): def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): """The marked entry says the file reads the sum so far, which alone is its own term.""" program = to_spec(HUB).program - assert program.expressions['injection'].additive, 'the entry folds into the definition' + assert 'injection' in program.expressions, 'the term is an ordinary definition' assert not program.given, 'a name this file defines is not one it reads from elsewhere' -def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): - narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} - narrow = { - **narrow, - 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, - } - with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): - to_spec(narrow) +#: The hub with its entry over `bus` alone, so its own term is wider than the sum it marks. +WIDE_HUB = { + **HUB, + 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, + 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, +} +#: The demand with its term written as `cases:`, beside the marked entry. +CASED_HUB = { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, +} -def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): - cased = { - **DEMAND, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, - 'expressions': { - 'injection': { - 'dims': ['snapshot', 'bus'], - 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, - 'otherwise': '0', - } - }, - } - with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): - to_spec(cased) + +@pytest.mark.parametrize('hub', [pytest.param(WIDE_HUB, id='wide-term'), pytest.param(CASED_HUB, id='cased-term')]) +def test_a_marked_file_s_own_term_is_held_to_no_rule_of_the_sum_at_load(hub): + """Alone the file reads its own term, which any named expression may be; only `merge` sums.""" + assert 'injection' in to_spec(hub).program.expressions + + +@pytest.mark.parametrize( + ('hub', 'message'), + [ + pytest.param(WIDE_HUB, r"'hub' adds a term to 'injection' over \['bus', 'snapshot'\]", id='wide-term'), + pytest.param(CASED_HUB, r"'hub' adds a term to 'injection' written as `cases:`", id='cased-term'), + ], +) +def test_a_marked_file_s_own_term_is_held_to_the_rules_of_the_sum_by_merge(hub, message): + with pytest.raises(LanguageError, match=message): + merge({'hub': hub, 'other': {'dimensions': DIMS}}) # --------------------------------------------------------------------------- @@ -129,7 +142,6 @@ def test_merging_sums_every_term_of_a_marked_name_in_fragment_name_order(): ) assert composed.given.expressions['injection'].additive, 'the marked entry is kept' assert not composed.program.given, 'and it folds into the definition, so the composed spec reads nothing' - assert composed.program.expressions['injection'].additive def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): @@ -146,9 +158,16 @@ def test_a_composed_model_takes_more_terms_in_a_second_merge(): assert 'gen_p' in extended.expressions['injection'].expression -def test_the_sum_takes_the_readers_description(): +def test_the_kept_entry_keeps_the_readers_description(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - assert composed.program.expressions['injection'].description == INJECTION + assert composed.given.expressions['injection'].description == INJECTION + assert composed.program.expressions['injection'].description is None, 'no term said what it is' + + +def test_the_sum_takes_the_first_term_s_description(): + said = {**DEMAND, 'expressions': {'injection': {'expression': '-load', 'description': 'less the demand'}}} + composed = merge({'fleet': FLEET, 'demand': said, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == 'less the demand' def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): @@ -162,7 +181,6 @@ def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): def test_one_term_and_an_unmarked_reader_is_an_ordinary_fold(): composed = merge({'fleet': FLEET, 'capped': CAPPED}) assert not composed.given - assert not composed.program.expressions['injection'].additive def test_one_reader_marks_the_sum_and_another_states_the_frame(): @@ -241,11 +259,12 @@ def test_the_reader_s_legend_says_other_files_add_to_it(): assert 'a sum other files add terms to' in given -def test_the_composed_legend_says_other_files_add_to_it(): +def test_the_composed_legend_lists_the_sum_as_any_definition(): definitions = to_markdown(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE})).split('#### Definitions')[ 1 ] - assert 'a sum other files add terms to' in definitions + assert '`injection`' in definitions + assert 'add terms to' not in definitions, 'the kept entry is what says the name is a sum, not the definition' @pytest.mark.parametrize('fmt', sorted(FORMATS)) From 2a1cf739c21d465446fdbdd91eff30a1983b7cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:54:41 +0000 Subject: [PATCH 54/75] Add the changelog link for #734 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc956e0..2bc5cc50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) -- refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum +- refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum ([#734](https://github.com/energy-models/mathspec/pull/734)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) From c60220589abdf0cc25a76ea08cb8548af6646d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:07:05 +0000 Subject: [PATCH 55/75] The reader's description wins, and the term checks stay at load Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- docs/reference/language/declarations.md | 20 +++---- src/mathspec/composition.py | 2 +- src/mathspec/dimensions.py | 10 ++++ src/mathspec/lowering.py | 3 +- src/mathspec/validation.py | 12 ++++ tests/test_additive.py | 74 +++++++++++-------------- 7 files changed, 68 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc5cc50..8a6b4ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) -- refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum ([#734](https://github.com/energy-models/mathspec/pull/734)) +- refactor(language): the sum flag lives on the given entry alone, and a definition carries none ([#734](https://github.com/energy-models/mathspec/pull/734)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 8fa44ec6..6041249b 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -283,19 +283,19 @@ Each file loads alone: the reader over a sum it does not build, and the contributor over its own term. Other readers state the frame and nothing more. A file may carry the marked entry and declare a term too. Then it reads the -sum so far, which alone is its own term. The file loads as any other: the -term is an ordinary named expression, and the typeset legend lists it under -_Definitions_ with no note. Only `merge` holds a term to the rules of a sum. +sum so far, which alone is its own term. The term is one `expression:`, and +carries no dimension the entry does not state; both are checked at load. The +entry then folds into the definition, and the typeset legend lists it under +_Definitions_. [`merge`](../../howto/compose.md#a-library-of-components) sums every term of a marked name, each in parentheses, in fragment-name order, and keeps the marked -entry, so a later merge adds more. The entry keeps its description, and the -sum takes the first term's. It refuses a term written as `cases:`, a term over -a dimension the entry does not state, and a file that declares a term and -reads the name without carrying the marked entry: on its own that file reads -its term, and composed it would read the sum. Two terms of a name no entry -marks are refused as a collision. A marked name no file adds to stays under -`given:`. +entry, so a later merge adds more. The entry's description is the sum's. It +refuses a term written as `cases:`, a term over a dimension the entry does not +state, and a file that declares a term and reads the name without carrying the +marked entry: on its own that file reads its term, and composed it would read +the sum. Two terms of a name no entry marks are refused as a collision. A +marked name no file adds to stays under `given:`. ## `constraints` diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index a45ac1dc..f476e448 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -304,7 +304,7 @@ def _summed_terms( 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) } said = next((b['description'] for _, b in terms if b.get('description')), None) - if said is not None: + if said is not None and not entry.get('description'): term['description'] = said summed[key] = term return summed diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index 1777d12c..dd81de51 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -292,6 +292,16 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) + for ename, sum_entry in schema.given.expressions.items(): + if not (sum_entry.additive and ename in program.expressions): + continue + if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): + raise DimensionError( + f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " + f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " + f'them out of the term.' + ) + for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index bdd0aebc..7b222005 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -222,7 +222,8 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description, + description=schema.expressions[name].description + or (schema.given.expressions[name].description if name in sums else None), ) for name, entry in entries.items() }, diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 653f70a5..1553e65e 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -89,6 +89,7 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_bounds(schema), *_piecewise_references(schema), *_given_constraint_collisions(schema), + *_cased_terms(schema), ] @@ -146,6 +147,17 @@ def _given_constraint_collisions(schema: Spec) -> Iterator[str]: ) +def _cased_terms(schema: Spec) -> Iterator[str]: + """A term of a sum is one expression, since the terms are summed as written.""" + for name in sorted(defined_sums(schema)): + if schema.expressions[name].cases: + yield ( + f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " + f'The terms are summed as written: name the cased term as its own expression, and write ' + f'that name as the term.' + ) + + def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ diff --git a/tests/test_additive.py b/tests/test_additive.py index d2975088..4b124c51 100644 --- a/tests/test_additive.py +++ b/tests/test_additive.py @@ -91,43 +91,30 @@ def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): assert not program.given, 'a name this file defines is not one it reads from elsewhere' -#: The hub with its entry over `bus` alone, so its own term is wider than the sum it marks. -WIDE_HUB = { - **HUB, - 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, - 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, -} - -#: The demand with its term written as `cases:`, beside the marked entry. -CASED_HUB = { - **DEMAND, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, - 'expressions': { - 'injection': { - 'dims': ['snapshot', 'bus'], - 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, - 'otherwise': '0', - } - }, -} - - -@pytest.mark.parametrize('hub', [pytest.param(WIDE_HUB, id='wide-term'), pytest.param(CASED_HUB, id='cased-term')]) -def test_a_marked_file_s_own_term_is_held_to_no_rule_of_the_sum_at_load(hub): - """Alone the file reads its own term, which any named expression may be; only `merge` sums.""" - assert 'injection' in to_spec(hub).program.expressions +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): + narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} + narrow = { + **narrow, + 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, + } + with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): + to_spec(narrow) -@pytest.mark.parametrize( - ('hub', 'message'), - [ - pytest.param(WIDE_HUB, r"'hub' adds a term to 'injection' over \['bus', 'snapshot'\]", id='wide-term'), - pytest.param(CASED_HUB, r"'hub' adds a term to 'injection' written as `cases:`", id='cased-term'), - ], -) -def test_a_marked_file_s_own_term_is_held_to_the_rules_of_the_sum_by_merge(hub, message): - with pytest.raises(LanguageError, match=message): - merge({'hub': hub, 'other': {'dimensions': DIMS}}) +def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): + cased = { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): + to_spec(cased) # --------------------------------------------------------------------------- @@ -158,15 +145,18 @@ def test_a_composed_model_takes_more_terms_in_a_second_merge(): assert 'gen_p' in extended.expressions['injection'].expression -def test_the_kept_entry_keeps_the_readers_description(): - composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - assert composed.given.expressions['injection'].description == INJECTION - assert composed.program.expressions['injection'].description is None, 'no term said what it is' +#: A term that says what it is. +SAID = {**DEMAND, 'expressions': {'injection': {'expression': '-load', 'description': 'less the demand'}}} + + +def test_the_sum_takes_the_readers_description(): + composed = merge({'fleet': FLEET, 'demand': SAID, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == INJECTION, 'the reader wins over a term' -def test_the_sum_takes_the_first_term_s_description(): - said = {**DEMAND, 'expressions': {'injection': {'expression': '-load', 'description': 'less the demand'}}} - composed = merge({'fleet': FLEET, 'demand': said, 'balance': BALANCE}) +def test_the_sum_takes_the_first_term_s_description_where_the_reader_says_nothing(): + silent = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}} + composed = merge({'fleet': FLEET, 'demand': SAID, 'balance': silent}) assert composed.program.expressions['injection'].description == 'less the demand' From 2b74b607100ae6ba1093f9df0b0337c5045e95e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:33:06 +0000 Subject: [PATCH 56/75] feat(language): a file adds a term to an expression another file defines, under `given:` Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- docs/howto/compose.md | 24 +- docs/reference/language/declarations.md | 97 ++++---- docs/reference/notation.md | 6 + docs/reference/reading.md | 6 +- docs/several-files.md | 98 ++++++-- schema/mathspec.schema.json | 19 +- src/mathspec/advising.py | 28 +-- src/mathspec/canonical.py | 2 +- src/mathspec/composition.py | 177 ++++++------- src/mathspec/dimensions.py | 12 +- src/mathspec/lowering.py | 25 +- src/mathspec/program.py | 6 +- src/mathspec/resolution.py | 5 +- src/mathspec/spec.py | 21 +- src/mathspec/typesetting/__init__.py | 18 +- src/mathspec/typesetting/format.py | 2 + src/mathspec/typesetting/legend.py | 6 +- src/mathspec/typesetting/walk.py | 37 ++- src/mathspec/validation.py | 16 +- tests/test_additive.py | 271 -------------------- tests/test_several_files_page.py | 2 +- tests/test_terms.py | 317 ++++++++++++++++++++++++ tests/typesetting/golden/latex.out | 8 +- tests/typesetting/golden/markdown.out | 12 + tests/typesetting/golden/model.yaml | 6 + tests/typesetting/golden/typst.out | 6 +- tests/typesetting/test_cli.py | 7 +- tests/typesetting/test_declaration.py | 2 +- tests/typesetting/test_walk.py | 2 +- 30 files changed, 696 insertions(+), 544 deletions(-) delete mode 100644 tests/test_additive.py create mode 100644 tests/test_terms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6b4ae0..d4729d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) -- refactor(language): the sum flag lives on the given entry alone, and a definition carries none ([#734](https://github.com/energy-models/mathspec/pull/734)) +- feat(language): a file adds a term to an expression another file defines, under `given:` ([#734](https://github.com/energy-models/mathspec/pull/734)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 896e4dc7..592b8730 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a name a `given:` entry marks `additive` | every fragment's declaration of it is a term: the terms are summed in fragment-name order, each in parentheses, and the marked entry is kept | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term in fragment-name order, each in parentheses | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 6041249b..65efc0e6 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -219,17 +219,17 @@ constraints: expression: injection == 0 ``` -| Field | | | -| ------------- | -------------------------------------------------------------------------------------------- | --------------- | -| `dims` | required. The dimensions the expression runs over | | -| `additive` | `true` where the name is a sum other files add terms to ([a sum](#a-sum-other-files-add-to)) | default `false` | -| `description` | free text | default `null` | - -There is no body. This file reads the name as it reads a given variable: a -quantity over the frame, of degree one. A `where` does not read it, because a -mask is built before any variable exists. A name declared under both -`expressions:` and `given: expressions:` is refused, unless the entry is -marked `additive`. The typeset legend lists a given expression under _Given_. +| Field | | | +| ------------- | ------------------------------------------------------------------------------- | -------------- | +| `dims` | required. The dimensions the expression runs over | | +| `term` | one expression: what this file adds to the name ([a term](#a-term-a-file-adds)) | default `null` | +| `description` | free text | default `null` | + +There is no body but the term this file adds, if any. This file reads the name +as it reads a given variable: a quantity over the frame, of degree one. A +`where` does not read it, because a mask is built before any variable exists. +A name declared under both `expressions:` and `given: expressions:` is +refused. The typeset legend lists a given expression under _Given_. [`merge`](../../howto/compose.md#a-library-of-components) folds a given expression into the definition of another fragment. The `dims` are an upper @@ -240,62 +240,61 @@ term carries it. The composed spec holds the body to the rules of every place this file reads it: a square of a given expression that is quadratic is refused once folded. -#### A sum other files add to +#### A term a file adds -`additive: true` says the name is a sum other files add terms to. One file -says it, on its `given:` entry, with the frame. Each file that adds a term -declares it as an ordinary named expression under that name. +`term:` is what this file adds to the name. The file reads the name as the +whole sum, alone and composed, and the term is its part of it. A file that +only reads the name writes no term. ```yaml -# balance.yaml reads the sum +# fleet.yaml adds a term dimensions: snapshot: { dtype: int } bus: { dtype: str } + generator: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } given: expressions: injection: dims: [snapshot, bus] - additive: true - description: what the components put into a bus -variables: - slack: { dims: [snapshot, bus] } -constraints: - balance: - dims: [snapshot, bus] - expression: injection + slack == 0 + term: sum(gen_p, by=gen_bus, over=generator, into=bus) ``` ```yaml -# fleet.yaml adds a term +# balance.yaml reads the sum dimensions: snapshot: { dtype: int } bus: { dtype: str } - generator: { dtype: str } -relations: - gen_bus: { key: generator, values: bus } -variables: - gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } -expressions: - injection: sum(gen_p, by=gen_bus, over=generator, into=bus) +given: + expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus +constraints: + balance: + dims: [snapshot, bus] + expression: injection == 0 ``` -Each file loads alone: the reader over a sum it does not build, and the -contributor over its own term. Other readers state the frame and nothing more. - -A file may carry the marked entry and declare a term too. Then it reads the -sum so far, which alone is its own term. The term is one `expression:`, and -carries no dimension the entry does not state; both are checked at load. The -entry then folds into the definition, and the typeset legend lists it under -_Definitions_. - -[`merge`](../../howto/compose.md#a-library-of-components) sums every term of a -marked name, each in parentheses, in fragment-name order, and keeps the marked -entry, so a later merge adds more. The entry's description is the sum's. It -refuses a term written as `cases:`, a term over a dimension the entry does not -state, and a file that declares a term and reads the name without carrying the -marked entry: on its own that file reads its term, and composed it would read -the sum. Two terms of a name no entry marks are refused as a collision. A -marked name no file adds to stays under `given:`. +Each file loads alone. A term is one expression, resolved in its own file: it +reads what the file declares, and not the name it adds to. It carries no +dimension the entry does not state, and it is held to degree two, as what +reads the sum is. All three are checked at load. The typeset legend lists the +entry under _Given_, and the math prints the term under _Definitions_ as +`injection = ⋯ + term`, the dots standing for what the other files add. + +[`merge`](../../howto/compose.md#a-library-of-components) defines the name as +the definition one fragment writes under `expressions:`, if any, plus every +term, each in parentheses, in fragment-name order. Nothing declares that the +name is a sum: a term adds to whatever the other files define, and a later +merge adds to the composed definition the same way. A definition written as +`cases:` is refused: name the cased body as its own expression, and define the +name as that name. The definition keeps its own description, or takes the +first a reader wrote. Two files that both define the name under `expressions:` +are refused as a collision, and the message names `term:`. ## `constraints` diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 030bc436..ac28210b 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -117,6 +117,12 @@ parameters: | $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{withdrawal}`$ | `withdrawal` over $`\mathcal{T} \times \mathcal{G}`$, an expression this file adds a term to | + #### Definitions | Symbol | Meaning | diff --git a/docs/reference/reading.md b/docs/reference/reading.md index b9a56ba4..23d18204 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -170,9 +170,9 @@ reads and does not build ([given](language/declarations.md#given)). Every other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. -A given expression with `additive` set is a sum other files add terms to. -Where the file also declares a term of it, the name is an ordinary definition -under `program.expressions`, and not a given name. +A given expression with a `term` is one this file adds to: +`program.given.expressions[name].term` is the term, resolved in this program, +and the name is still one the program reads and does not build. ```python layer = to_spec( diff --git a/docs/several-files.md b/docs/several-files.md index ff0c7bde..09774688 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -13,7 +13,7 @@ other files. Do [your first spec](first-spec.md) first. Make a file `network.yaml`. It balances every bus, and it reads the injection at a bus under [`given:`](reference/language/declarations.md#given) rather -than defining it. `additive: true` says that other files add terms to it: +than defining it: ```yaml title="network.yaml" description: Every bus is balanced in every snapshot. @@ -26,7 +26,6 @@ given: expressions: injection: dims: [snapshot, bus] - additive: true description: what the components put into a bus, less what they take out constraints: @@ -41,16 +40,17 @@ Check the file: python -m mathspec check network.yaml ``` -The check accepts it, and notes that no file has added a term yet: +The check accepts it, and notes the expression it reads: ```text -expression 'injection' is a sum other files add terms to, and this file adds none: merge() sums the term every fragment declares under the name. Until one does, the program reads it and does not build it. +expression 'injection' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces. ``` ## The generators -Make a file `generators.yaml`. Its term of the injection is an ordinary named -expression under the same name: +Make a file `generators.yaml`. It reads the injection too, and it says what it +puts in: a [`term:`](reference/language/declarations.md#a-term-a-file-adds) +on the entry. ```yaml title="generators.yaml" description: A generator fleet, each unit on one bus. @@ -73,20 +73,65 @@ variables: dims: [snapshot, generator] bounds: { lower: 0, upper: capacity } -expressions: - injection: sum(dispatch, by=gen_bus, over=generator, into=bus) +given: + expressions: + injection: + dims: [snapshot, bus] + term: sum(dispatch, by=gen_bus, over=generator, into=bus) objective: sense: minimize expression: sum(dispatch * cost) ``` -Check the file. The check prints nothing and exits with status 0: +Check the file: ```bash python -m mathspec check generators.yaml ``` +The check accepts it, and notes the term: + +```text +expression 'injection' is read here and declared elsewhere, and this file adds a term to it: merge() sums the term with what the other files declare under the name. Until then, the program reads it and does not build it. +``` + +Print the math of the file on its own: + +```python +import mathspec as ms + +print(ms.to_markdown('generators.yaml', legend=False)) +``` + +The term prints after dots, which stand for what the other files put in: + +!!! example "Rendered output" + + A generator fleet, each unit on one bus. + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} + ``` + + #### Definitions + + **`injection`** + + ```math + \mathit{injection}_{t,b} = \cdots + \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + #### Variable domains + + **`dispatch`** + + ```math + 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + ## The loads Make a file `loads.yaml`. Its term takes the demand out of the bus: @@ -101,23 +146,30 @@ dimensions: parameters: demand: { dims: [snapshot, bus], description: demand to be met } -expressions: - injection: -demand +given: + expressions: + injection: + dims: [snapshot, bus] + term: -demand ``` -Check the file. The check prints nothing and exits with status 0: +Check the file: ```bash python -m mathspec check loads.yaml ``` +The check accepts it, with the same note: + +```text +expression 'injection' is read here and declared elsewhere, and this file adds a term to it: merge() sums the term with what the other files declare under the name. Until then, the program reads it and does not build it. +``` + ## Merge the files Merge the three files in Python. Each name is what an error calls that file: ```python -import mathspec as ms - spec = ms.merge({'network': 'network.yaml', 'generators': 'generators.yaml', 'loads': 'loads.yaml'}) print(spec.expressions['injection'].expression) ``` @@ -188,8 +240,11 @@ variables: dims: [snapshot, bus] bounds: { lower: 0, upper: import_limit } -expressions: - injection: imported +given: + expressions: + injection: + dims: [snapshot, bus] + term: imported objective: sense: minimize @@ -281,14 +336,17 @@ False Merge the generators and the loads without the network: ```python -ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) +spec = ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) +print(spec.expressions['injection'].expression) +print(sorted(spec.constraints)) ``` -`merge` refuses it. Without the file that marks `injection` as a sum, the two -terms are two definitions of one name: +`merge` accepts it. The two terms still sum to a definition, and no +constraint reads it, because the balance is the network's: ```text -fragments 'generators' and 'loads' both declare the expression 'injection'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the fragment once, and let the data carry both. Different math under one spelling is a rename: call one of them something else. If each fragment adds a term to one sum, mark the name `additive: true` on a `given: expressions:` entry in the file that reads it. +(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (-demand) +[] ``` ## Where to next diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 27b6d7d9..bb83e7f3 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -342,13 +342,8 @@ }, "GivenExpressionBlock": { "additionalProperties": false, - "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``additive: true`` says the name is a sum other files add terms to. Each\nof them declares its term as an ordinary named expression under the name,\nand [`merge`][mathspec.composition.merge] sums the terms. A file that marks\nthe name may declare a term of its own too, and then reads the sum so far.", + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``term:`` is what this file adds to the name. It is one expression, read\nover at most the frame, and [`merge`][mathspec.composition.merge] sums it\nwith the definition another file writes and the terms other files add.\nThe file itself reads the name as the whole sum, alone and composed.", "properties": { - "additive": { - "default": false, - "title": "Additive", - "type": "boolean" - }, "description": { "anyOf": [ { @@ -367,6 +362,18 @@ }, "title": "Dims", "type": "array" + }, + "term": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Term" } }, "required": [ diff --git a/src/mathspec/advising.py b/src/mathspec/advising.py index 1347c7d5..21c0163e 100644 --- a/src/mathspec/advising.py +++ b/src/mathspec/advising.py @@ -52,18 +52,18 @@ def _given(program: Program) -> list[Advice]: A note rather than a refusal: the file is a spec somebody meant, and only the consumer can tell whether the model it is layered onto provides the - name. A sum other files add terms to is completed by `merge` rather than - by a host, so its note says that instead. + name. An expression this file adds a term to is completed by `merge` + rather than by a host, so its note says that instead. """ + given = program.given return [ - Advice('given', name, _sum_note(name) if getattr(block, 'additive', False) else _given_note(kind, name)) - for kind, group in ( - ('parameter', program.given.parameters), - ('variable', program.given.variables), - ('expression', program.given.expressions), - ('row family', program.given.constraints), - ) - for name, block in group.items() + *(Advice('given', name, _given_note('parameter', name)) for name in given.parameters), + *(Advice('given', name, _given_note('variable', name)) for name in given.variables), + *( + Advice('given', name, _term_note(name) if block.term is not None else _given_note('expression', name)) + for name, block in given.expressions.items() + ), + *(Advice('given', name, _given_note('row family', name)) for name in given.constraints), ] @@ -76,11 +76,11 @@ def _given_note(kind: str, name: str) -> str: ) -def _sum_note(name: str) -> str: +def _term_note(name: str) -> str: return ( - f"expression '{name}' is a sum other files add terms to, and this file adds none: merge() sums the " - f'term every fragment declares under the name. Until one does, the program reads it and does not ' - f'build it.' + f"expression '{name}' is read here and declared elsewhere, and this file adds a term to it: merge() " + f'sums the term with what the other files declare under the name. Until then, the program reads it ' + f'and does not build it.' ) diff --git a/src/mathspec/canonical.py b/src/mathspec/canonical.py index 8198d3a8..bb0330cd 100644 --- a/src/mathspec/canonical.py +++ b/src/mathspec/canonical.py @@ -33,7 +33,7 @@ #: plain data. A link is the odd one: it serialises back to the ``[expression, #: values]`` list the file wrote, so its expression is a position rather than a #: key. -_EXPRESSION_KEYS = ('expression', 'otherwise', 'template') +_EXPRESSION_KEYS = ('expression', 'otherwise', 'template', 'term') def _signed(sign: Sign, node: ArithmeticNode) -> tuple[Sign, ArithmeticNode]: diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index f476e448..2b3a218e 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -12,7 +12,11 @@ ``override(merge({...}), {...})``, which builds the spec and then configures the run. -What [`merge`][] does with each section: +[`merge`][] writes nothing a fragment did not write, except a ``+``. It +joins two fragments' text in two places, the objective and a named expression +that terms add to; every other block is copied as written, refused where two +fragments own it, or held identical where it is a dimension or a relation. +What that means for each section: * **A dimension or a relation every fragment may declare**, and the ones that do have to say the same thing about it. Prose is not a claim, so two @@ -21,20 +25,17 @@ both named. * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. -* **A sum is summed the same way.** A ``given: expressions:`` entry marked - ``additive: true`` says the name is a sum other files add terms to. Every - fragment's declaration of it is a term, and the composed spec declares their - sum and keeps the marked entry, so a later merge adds more. A term may carry - no dimension the entry does not state, and is one ``expression:``. A - fragment that declares a term and reads the name without carrying the marked - entry is refused: on its own it reads its term, and composed it would read - the sum. +* **A term is added to the definition it names.** A ``given: expressions:`` + entry with a ``term:`` is what its fragment adds to the name. The composed + spec defines the name as the definition one fragment writes, if any, plus + every term, each in parentheses, in the fragments' name order. A definition + written as ``cases:`` is refused, since the terms are summed as written. A + later merge adds to the composed definition the same way. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, and a name read as one kind and introduced as another is refused. - Two fragments that both only read a name have to read it the same way, and a - fragment that declares a name and reads it as well is refused. What no + Two fragments that both read a name have to read it over one frame. What no fragment introduces stays under ``given:`` until a host model provides it. A patch says only what it changes, because declarations are laid over a field @@ -79,8 +80,8 @@ from pydantic import BaseModel, ValidationError from mathspec._yaml import read_spec +from mathspec.dimensions import dims_of from mathspec.errors import LanguageError, did_you_mean, schema_error -from mathspec.program import Named, walk from mathspec.spec import GivenBlock, Spec from mathspec.validation import to_spec @@ -158,11 +159,10 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des merged[section] = agreed asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} readings = _agreed_readings(asked) - sums = {key: entry for key, entry in readings.items() if entry['additive']} for section in OWNED_SECTIONS: - if claimed := _claimed(read, section, sums if section == 'expressions' else {}): + if claimed := _claimed(read, section): merged[section] = claimed - if summed := _summed_terms(read, loaded, sums): + if summed := _summed(read, _mapping(merged.get('expressions')), readings): merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} if given := _folded(read, merged, loaded, readings): merged['given'] = given @@ -226,20 +226,15 @@ def _claims(block: object) -> object: return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block -def _claimed(read: Mapping[str, dict[str, object]], section: str, sums: Mapping[str, object]) -> dict[str, object]: - """One block of owned declarations, a name claimed twice being the refusal. - - The terms of a sum are left to [`_summed_terms`][]. - """ +def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: + """One block of owned declarations, a name claimed twice being the refusal.""" merged: dict[str, object] = {} for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): - if key in sums: - continue if key in merged: hint = ( - ' If each fragment adds a term to one sum, mark the name `additive: true` on a ' - '`given: expressions:` entry in the file that reads it.' + " If one fragment adds to the other's definition, write what it adds as `term:` under " + '`given: expressions:`.' if section == 'expressions' else '' ) @@ -254,18 +249,18 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str, sums: Mapping[ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: - """Every ``given: expressions:`` entry, the ones two fragments share folded together. + """Every ``given: expressions:`` entry, the ones two fragments share folded together, without their terms. - Two readings of one name agree on the frame, compared as a set. Prose is - not a claim, and the flag is said once for every reader, so the first - description and any reader's flag are carried. + Two readings of one name agree on the frame, compared as a set. A term is + the fragment's own and is summed by [`_summed`][], so it is no claim about + the name; prose is not one either, so the first description is carried. """ agreed: dict[str, dict[str, object]] = {} for name, given in asked.items(): for key, block in _mapping(given.get('expressions')).items(): - entry = cast('dict[str, object]', block) + entry = {field: value for field, value in cast('dict[str, object]', block).items() if field != 'term'} if key not in agreed: - agreed[key] = dict(entry) + agreed[key] = entry continue held = agreed[key] if set(cast('list[str]', held['dims'])) != set(cast('list[str]', entry['dims'])): @@ -274,39 +269,53 @@ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[s f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' f'files read one name over one frame: make the two identical.' ) - held['additive'] = bool(held.get('additive') or entry.get('additive')) held['description'] = held.get('description') or entry.get('description') return agreed -def _summed_terms( - read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], sums: Mapping[str, dict[str, object]] +def _terms(read: Mapping[str, dict[str, object]], key: str) -> list[tuple[str, str]]: + """Every term the fragments add to *key*, with the fragment that adds it, in the fragments' name order.""" + found = [] + for name, sections in sorted(read.items()): + entry = _mapping(_mapping(_mapping(sections.get('given')).get('expressions')).get(key)) + if entry.get('term') is not None: + found.append((name, cast('str', entry['term']))) + return found + + +def _summed( + read: Mapping[str, dict[str, object]], defined: Mapping[str, object], readings: Mapping[str, dict[str, object]] ) -> dict[str, object]: - """Every marked sum, its terms summed in the fragments' name order, each in parentheses. + """Every name a fragment adds a term to, defined as the definition plus the terms. - One term is carried as written. A term written as ``cases:``, a term over a - dimension the marked entry does not state, and a fragment that declares a - term and reads the name without carrying the marked entry are refused. + The definition one fragment writes comes first, then every term in the + fragments' name order, each in parentheses; one body alone is carried as + written. A definition written as ``cases:`` is refused, since the terms + are summed as written and a set of cases is no one body. The definition + keeps its own description, or takes the first a reader wrote. """ summed: dict[str, object] = {} - for key, entry in sums.items(): - terms = [ - (name, _as_mapping(_mapping(sections.get('expressions'))[key])) - for name, sections in sorted(read.items()) - if key in _mapping(sections.get('expressions')) - ] - for name, block in terms: - _one_term(key, entry, name, block, loaded[name]) - bodies = [cast('str', block['expression']) for _, block in terms] - if not bodies: + for key, entry in readings.items(): + terms = _terms(read, key) + if not terms: continue - term: dict[str, object] = { - 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) - } - said = next((b['description'] for _, b in terms if b.get('description')), None) - if said is not None and not entry.get('description'): - term['description'] = said - summed[key] = term + bodies = [term for _, term in terms] + block: dict[str, object] = {} + if key in defined: + base = _as_mapping(defined[key]) + if base.get('cases'): + raise LanguageError( + f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " + f"'{terms[0][0]}' adds a term to it. The terms are summed as written, and a set of cases is no " + f'one body: name the cased body as its own expression, and define {key!r} as that name.' + ) + bodies.insert(0, cast('str', base['expression'])) + if base.get('description'): + block['description'] = base['description'] + block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) + if 'description' not in block and entry.get('description'): + block['description'] = entry['description'] + summed[key] = block return summed @@ -315,38 +324,6 @@ def _as_mapping(block: object) -> dict[str, object]: return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} -def _one_term(key: str, entry: Mapping[str, object], name: str, block: Mapping[str, object], spec: Spec) -> None: - """Refuse a term of the sum *key* that cannot be summed as written, or that its own file misreads.""" - if block.get('cases'): - raise LanguageError( - f"fragment '{name}' adds a term to {key!r} written as `cases:`. The terms of a sum are summed as " - f'written, and a set of cases is no one body: name the cased term as its own expression in ' - f"'{name}', and write that name as the term." - ) - stated = cast('list[str]', entry['dims']) - frame = spec.program.expressions[key].dims - if extra := sorted(set(frame) - set(stated)): - raise LanguageError( - f"fragment '{name}' adds a term to {key!r} over {sorted(frame)}, where the sum is over " - f'{sorted(stated)}. A term carries no dimension its sum does not state: add {extra} to the ' - f'dims of the marked `given:` entry, or leave them out of the term.' - ) - marks = key in spec.given.expressions and spec.given.expressions[key].additive - if not marks and _reads(spec, key): - raise LanguageError( - f"fragment '{name}' adds a term to {key!r} and reads it. On its own the fragment reads its term, " - f'and composed it would read the sum of every term: mark {key!r} `additive: true` under ' - f"'given: expressions:' in '{name}' to read the sum, or read it in another file." - ) - - -def _reads(spec: Spec, key: str) -> bool: - """Whether the math or another named expression of *spec* names *key*.""" - program = spec.program - others = [entry.expression for name, entry in program.expressions.items() if name != key] - return any(isinstance(node, Named) and node.name == key for node in walk(*program.roots, *others)) - - def _folded( read: Mapping[str, dict[str, object]], merged: Mapping[str, object], @@ -358,9 +335,8 @@ def _folded( A given declaration is what a fragment expects of a name a sibling owns. Where the sibling is in the composition the expectation is checked and then dropped, so the composed spec declares the name once. A given - expression is checked against the frame of the definition's body: the body - carries no dimension the reader does not state. A marked entry is kept - beside the sum, so the composed spec still says the name is one. + expression is checked against the frame of the composed body: the body + carries no dimension the reader does not state. """ asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} left: dict[str, object] = {} @@ -369,8 +345,6 @@ def _folded( agreed = readings if kind == 'expressions' else _agreed(asked, kind, label) for key, block in agreed.items(): _same_kind(asked, read, merged, kind, key) - if kind == 'expressions' and _mapping(block).get('additive'): - continue if key in introduced and kind == 'expressions': _within_frame(asked, read, key, block, _definer_frame(loaded, key)) elif key in introduced and not _says_less(block, introduced[key]): @@ -380,19 +354,22 @@ def _folded( f'says the same as the declaration it is folded into, or less: restate the frame as the ' f'introducer declares it, or leave the field out.' ) - kept = { - key: block - for key, block in agreed.items() - if key not in introduced or (kind == 'expressions' and _mapping(block).get('additive')) - } + kept = {key: block for key, block in agreed.items() if key not in introduced} if kept: left[kind] = kept return left -def _definer_frame(loaded: Mapping[str, Spec], key: str) -> tuple[str, ...]: - """The frame of the one definition of *key*, as the fragment that writes it reads it.""" - return next(spec.program.expressions[key].dims for spec in loaded.values() if key in spec.program.expressions) +def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: + """The frame of the composed body of *key*: the definition's, where a fragment writes one, with every term's.""" + frame: set[str] = set() + for spec in loaded.values(): + if key in spec.program.expressions: + frame |= set(spec.program.expressions[key].dims) + given = spec.program.given.expressions.get(key) + if given is not None and given.term is not None: + frame |= dims_of(given.term, spec, f"Given expression '{key}'") + return frozenset(frame) #: The given kinds whose names share the flat namespace an expression reads. @@ -424,7 +401,7 @@ def _within_frame( read: Mapping[str, dict[str, object]], key: str, block: object, - frame: tuple[str, ...], + frame: frozenset[str], ) -> None: """Refuse a definition whose body carries a dimension the given expression's reader does not state. diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index dd81de51..b7c2402f 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -292,14 +292,14 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) - for ename, sum_entry in schema.given.expressions.items(): - if not (sum_entry.additive and ename in program.expressions): + for gname, given in program.given.expressions.items(): + if given.term is None: continue - if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): + context = f"Given expression '{gname}'" + if extra := sorted(dims_of(given.term, schema, context) - set(given.dims)): raise DimensionError( - f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " - f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " - f'them out of the term.' + f'{context}: its term carries {extra}, which its dims {list(given.dims)} do not. A term is read ' + f"over the frame the entry states: add {extra} to the entry's dims, or leave them out of the term." ) for cname, constraint in program.constraints.items(): diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 7b222005..714786b3 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -40,6 +40,7 @@ PiecewiseDeclaration, Program, SosDeclaration, + Variable, VariableDeclaration, VariableDefined, walk, @@ -52,7 +53,6 @@ resolve_expression_text, resolve_where_text, ) -from mathspec.spec import defined_sums from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: @@ -125,6 +125,22 @@ def lower(schema: Spec) -> Program: if node is not None: entries[ename] = node + terms: dict[str, Expression] = {} + for gname, gdef in schema.given.expressions.items(): + if gdef.term is None: + continue + context = f"Given expression '{gname}'" + term = resolve_expression_text(gdef.term, ns, context, errors, ceiling=2) + if term is None: + continue + if any(isinstance(node, Variable) and node.name == gname for node in walk(term)): + errors.append( + f"{context}: its term reads '{gname}', the sum the term adds to, so the sum would define itself. " + f'A term is what this file puts in: write it in what this file declares.' + ) + continue + terms[gname] = term + variables = {} for vname, vdef in schema.variables.items(): where = resolve_where_text(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname) @@ -198,7 +214,6 @@ def lower(schema: Spec) -> Program: assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption - sums = defined_sums(schema) program = Program( parameters={ name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) @@ -222,8 +237,7 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description - or (schema.given.expressions[name].description if name in sums else None), + description=schema.expressions[name].description, ) for name, entry in entries.items() }, @@ -239,9 +253,8 @@ def lower(schema: Spec) -> Program: name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() }, expressions={ - name: GivenDeclaration(tuple(g.dims), g.description, additive=g.additive) + name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name)) for name, g in schema.given.expressions.items() - if name not in sums }, ), description=schema.description, diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 7de4448a..aecedd70 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -622,8 +622,10 @@ class GivenDeclaration: dims: tuple[str, ...] description: str | None = None - #: A given expression marked as a sum other files add terms to. - additive: bool = False + #: The term this program adds to a given expression, or ``None`` where it + #: only reads the name. Resolved in this program's namespace, and read + #: over at most ``dims``. + term: Expression | None = None @dataclass(frozen=True) diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index d08f2cb7..661770f5 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -46,7 +46,6 @@ RelationDeclaration, carries_variable, ) -from mathspec.spec import defined_sums if TYPE_CHECKING: from collections.abc import Iterable @@ -87,9 +86,7 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - defined = defined_sums(schema) - given = {name: g for name, g in schema.given.expressions.items() if name not in defined} - variables = {**schema.variables, **schema.given.variables, **given} + variables = {**schema.variables, **schema.given.variables, **schema.given.expressions} parameters = {**schema.parameters, **schema.given.parameters} #: Every name an expression reads as a column: the variables, and the #: given expressions, whose bodies another file holds. diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index bed29f08..5b37d3f8 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -334,17 +334,17 @@ class GivenExpressionBlock(_StrictBlock): body is the definer's, and the composed spec holds the body to the rules of every place this file reads it. - ``additive: true`` says the name is a sum other files add terms to. Each - of them declares its term as an ordinary named expression under the name, - and [`merge`][mathspec.composition.merge] sums the terms. A file that marks - the name may declare a term of its own too, and then reads the sum so far. + ``term:`` is what this file adds to the name. It is one expression, read + over at most the frame, and [`merge`][mathspec.composition.merge] sums it + with the definition another file writes and the terms other files add. + The file itself reads the name as the whole sum, alone and composed. """ _label: ClassVar[str] = 'a given expression declaration' dims: list[str] - #: Whether the name is a sum other files add terms to. - additive: bool = False + #: The term this file adds to the name, or ``None`` where it only reads it. + term: str | None = None description: str | None = None @@ -997,15 +997,6 @@ def _lower(self) -> Spec: return self -def defined_sums(schema: Spec) -> frozenset[str]: - """The names *schema* marks ``additive`` under ``given: expressions:`` and declares a term of itself. - - Such a name is defined in the file, and reads as the sum so far: it is not - a name the file reads from elsewhere, so it joins no given group. - """ - return frozenset(name for name, g in schema.given.expressions.items() if g.additive and name in schema.expressions) - - def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in [`FORMULATIONS`][] order — all of them where it names none. diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 6a85ebd8..3824412a 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -176,8 +176,9 @@ def typeset_declaration( """Render one declaration as the bare line the document prints for it. The line the whole-spec render prints for it — a named expression's - definition, a constraint, an assumption, a ``piecewise:`` curve, or a - variable's domain, quantifier included — + definition, the term this file adds to a given expression, a constraint, + an assumption, a ``piecewise:`` curve, or a variable's domain, quantifier + included — with no document, label, equation number or math delimiters around it, for a math context the caller lays out: a docstring, a table cell. A line on its own has no Definitions section beside it, so the plain named @@ -187,7 +188,8 @@ def typeset_declaration( Args: spec: Anything [`mathspec.to_spec`][] accepts, or a [`Program`][]. name: A named expression, constraint, assumption, ``piecewise:`` - block or variable the spec declares. + block or variable the spec declares, or a given expression it adds + a term to. fmt: What spells the math — a key of [`FORMATS`][]. symbols: How names print; see [`typeset`][]. inline_expressions: Substitute the plain named expressions the line uses, so it @@ -201,17 +203,17 @@ def typeset_declaration( Raises: ValueError: *fmt* names no format. LanguageError: A spec that does not compile; it does not print. - SchemaError: *name* is declared as none of the five, as two — a - constraint may share a variable's name — or under ``given:``, which - prints in the legend rather than as a line; or a symbol table entry - names nothing in the spec. + SchemaError: *name* is declared as none of the six, as two — a + constraint may share a variable's name — or under ``given:`` with + no term, which prints in the legend rather than as a line; or a + symbol table entry names nothing in the spec. """ walk = _walk(spec, fmt, symbols, inline_expressions=inline_expressions) given = walk.program.given givens = { 'parameter': given.parameters, 'variable': given.variables, - 'expression': given.expressions, + 'expression': {name: block for name, block in given.expressions.items() if block.term is None}, 'constraint': given.constraints, } given_kind = next((kind for kind, group in givens.items() if name in group), None) diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index fe888114..558c9703 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -62,6 +62,7 @@ 'dual', 'minimize', 'maximize', + 'ellipsis', ] #: Every operator a walk can emit, by the name the walk uses for it, with its @@ -108,6 +109,7 @@ 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), 'maximize': (r'\max', 'max'), + 'ellipsis': (r'\cdots', 'dots.c'), } #: The set form, for the test pinning each format's table against the vocabulary. diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index ff0ad745..416342cc 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -172,7 +172,11 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self._entry( self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, ' - + ('a sum other files add terms to' if block.additive else 'an expression another file defines'), + + ( + 'an expression this file adds a term to' + if block.term is not None + else 'an expression another file defines' + ), block.description, ) for g, block in program.given.expressions.items() diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 7f3b4f40..fb413f73 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -696,7 +696,7 @@ def _constraint(self, name: str) -> Line: ) def _definitions(self) -> list[Line]: - """One line per named expression, in declaration order, defining it. + """One line per named expression, in declaration order, defining it, then one per term this program adds. A use prints the symbol and the block prints here, as a paper states a quantity it names. Every declared one prints, used or not. Inlining @@ -704,7 +704,35 @@ def _definitions(self) -> list[Line]: no single body to substitute, and an entry the math never reads has nowhere to be substituted *into*, so both still print. """ - return [self.definition(name) for name in self.defined()] + return [*(self.definition(name) for name in self.defined()), *(self.term(name) for name in self.terms())] + + def terms(self) -> list[str]: + """The given expressions this program adds a term to, in declaration order.""" + return [name for name, given in self.program.given.expressions.items() if given.term is not None] + + def term(self, name: str) -> Line: + """The line for the term this program adds to a given expression: ``symbol = ⋯ + term`` over the entry's frame. + + The dots stand for what the other files put in. A negated term prints + as a subtraction, and is bracketed where [`_binary`][] would bracket + the right operand of one. + """ + given = self.program.given.expressions[name] + assert given.term is not None, 'a line prints for a term, and not for a name this program only reads' + frame = list(given.dims) + ctx = self._context(frame) + operand: Expression = given.term + op: BinaryOperator = '+' + while (unsigned := _unsigned(operand)) is not None: + operand, op = unsigned, '-' if op == '+' else '+' + rendered = self._expression(operand, ctx, need=_PRECEDENCE[op] + (1 if op == '-' else 0)) + sign = self._op('plus' if op == '+' else 'minus') + return Line( + label=name, + left=ctx.indexed(self.symbols.name[name], frame), + right=f'{self._op("equal")} {self._op("ellipsis")} {sign} {rendered}', + condition=self._quantifier(frame, ''), + ) def defined(self) -> list[str]: """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining. @@ -732,7 +760,7 @@ def definition(self, name: str) -> Line: ) def line(self, name: str) -> Line: - """The one line *name* prints as: a named expression, a constraint, an assumption, a curve, or a variable's domain. + """The one line *name* prints as: a named expression, a term this program adds, a constraint, an assumption, a curve, or a variable's domain. An assumption is looked up where the document prints it from, so a condition a curve's method states is a line a reader can ask for @@ -746,6 +774,7 @@ def line(self, name: str) -> Line: program = self.program kinds = { 'named expression': (program.expressions, self.definition), + 'term': (dict.fromkeys(self.terms()), self.term), 'constraint': (program.constraints, self._constraint), 'assumption': (program.assumptions, self._assumption), 'curve': (program.piecewise, self._piecewise), @@ -755,7 +784,7 @@ def line(self, name: str) -> Line: if not found: everything = {n for group, _ in kinds.values() for n in group} msg = ( - f"'{name}' is not a named expression, constraint, assumption, curve or variable. " + f"'{name}' is not a named expression, term, constraint, assumption, curve or variable. " f'{did_you_mean(name, everything)}' ) raise SchemaError(msg) diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 1553e65e..3b7424ae 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -28,7 +28,7 @@ from mathspec.piecewise import Emitted as EmittedCurve from mathspec.sos import Emitted as EmittedSet from mathspec.sos import coefficients -from mathspec.spec import NUMERIC_DTYPES, Spec, defined_sums, side_columns +from mathspec.spec import NUMERIC_DTYPES, Spec, side_columns if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -89,7 +89,6 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_bounds(schema), *_piecewise_references(schema), *_given_constraint_collisions(schema), - *_cased_terms(schema), ] @@ -108,7 +107,7 @@ def _flat_namespace(schema: Spec) -> list[tuple[str, Iterable[str]]]: ('variable', schema.variables), ('given variable', schema.given.variables), ('named expression', schema.expressions), - ('given expression', {n: g for n, g in schema.given.expressions.items() if n not in defined_sums(schema)}), + ('given expression', schema.given.expressions), ('macro', schema.macros), ] @@ -147,17 +146,6 @@ def _given_constraint_collisions(schema: Spec) -> Iterator[str]: ) -def _cased_terms(schema: Spec) -> Iterator[str]: - """A term of a sum is one expression, since the terms are summed as written.""" - for name in sorted(defined_sums(schema)): - if schema.expressions[name].cases: - yield ( - f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " - f'The terms are summed as written: name the cased term as its own expression, and write ' - f'that name as the term.' - ) - - def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ diff --git a/tests/test_additive.py b/tests/test_additive.py deleted file mode 100644 index 4b124c51..00000000 --- a/tests/test_additive.py +++ /dev/null @@ -1,271 +0,0 @@ -# SPDX-FileCopyrightText: mathspec Contributors -# -# SPDX-License-Identifier: MIT - -"""A named expression several fragments add terms to. - -A balance reads what every component puts into a bus, and a component file -says what it puts there. The file that reads the total marks it -`additive: true` on its `given:` entry, and states its frame once. Each -component declares its term as an ordinary named expression under that name, -and `merge` sums the terms. The marked entry stays in the composed spec, so a -later merge adds more. -""" - -from __future__ import annotations - -import pytest - -from mathspec import FORMATS, LanguageError, advice, merge, override, to_markdown, to_spec, typeset - -DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} -INJECTION = 'what the components put into a bus' - -#: The balance: it says `injection` is a sum over its frame, and adds nothing to it. -BALANCE = { - 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True, 'description': INJECTION}}}, - 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, -} - -#: A generator fleet: its term is an ordinary named expression. -FLEET = { - 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, - 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, - 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, - 'expressions': {'injection': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}, - 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, -} - -#: A demand, subtracting what it draws. -DEMAND = { - 'dimensions': DIMS, - 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, - 'expressions': {'injection': '-load'}, -} - -#: A store, added in a second merge. -STORAGE = { - 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, - 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, - 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, - 'expressions': {'injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, -} - -#: A reader that states the frame and does not say the name is a sum. -CAPPED = { - 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, - 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, -} - - -# --------------------------------------------------------------------------- -# one file -# --------------------------------------------------------------------------- - - -def test_a_reader_marks_the_sum_and_loads_on_its_own(): - program = to_spec(BALANCE).program - assert program.given.expressions['injection'].additive - assert program.given.expressions['injection'].dims == ('snapshot', 'bus') - - -def test_a_term_is_an_ordinary_named_expression(): - program = to_spec(FLEET).program - assert 'injection' in program.expressions and not program.given, 'nothing in a plain contributor says it is a term' - - -#: A file that adds a term of its own and reads the sum: it carries both. -HUB = { - **FLEET, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, - 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, -} - - -def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): - """The marked entry says the file reads the sum so far, which alone is its own term.""" - program = to_spec(HUB).program - assert 'injection' in program.expressions, 'the term is an ordinary definition' - assert not program.given, 'a name this file defines is not one it reads from elsewhere' - - -def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): - narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} - narrow = { - **narrow, - 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, - } - with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): - to_spec(narrow) - - -def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): - cased = { - **DEMAND, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, - 'expressions': { - 'injection': { - 'dims': ['snapshot', 'bus'], - 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, - 'otherwise': '0', - } - }, - } - with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): - to_spec(cased) - - -# --------------------------------------------------------------------------- -# merge -# --------------------------------------------------------------------------- - - -def test_merging_sums_every_term_of_a_marked_name_in_fragment_name_order(): - composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - assert composed.expressions['injection'].expression == ( - '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' - ) - assert composed.given.expressions['injection'].additive, 'the marked entry is kept' - assert not composed.program.given, 'and it folds into the definition, so the composed spec reads nothing' - - -def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): - one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) - assert one == other - - -def test_a_composed_model_takes_more_terms_in_a_second_merge(): - """The kept entry is what lets a framework ship a composed spec that a project adds to.""" - shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) - extended = merge({'shipped': shipped, 'storage': STORAGE}) - assert 'store_p' in extended.expressions['injection'].expression - assert 'gen_p' in extended.expressions['injection'].expression - - -#: A term that says what it is. -SAID = {**DEMAND, 'expressions': {'injection': {'expression': '-load', 'description': 'less the demand'}}} - - -def test_the_sum_takes_the_readers_description(): - composed = merge({'fleet': FLEET, 'demand': SAID, 'balance': BALANCE}) - assert composed.program.expressions['injection'].description == INJECTION, 'the reader wins over a term' - - -def test_the_sum_takes_the_first_term_s_description_where_the_reader_says_nothing(): - silent = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}} - composed = merge({'fleet': FLEET, 'demand': SAID, 'balance': silent}) - assert composed.program.expressions['injection'].description == 'less the demand' - - -def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): - with pytest.raises(LanguageError) as raised: - merge({'fleet': FLEET, 'demand': DEMAND}) - message = str(raised.value) - assert "both declare the expression 'injection'" in message - assert '`additive: true` on a `given: expressions:` entry' in message, 'the collision says how to make it a sum' - - -def test_one_term_and_an_unmarked_reader_is_an_ordinary_fold(): - composed = merge({'fleet': FLEET, 'capped': CAPPED}) - assert not composed.given - - -def test_one_reader_marks_the_sum_and_another_states_the_frame(): - composed = merge({'balance': BALANCE, 'capped': CAPPED, 'demand': DEMAND, 'fleet': FLEET}) - assert composed.given.expressions['injection'].additive, 'the flag comes from either entry' - assert sorted(composed.constraints) == ['balance', 'capped'] - - -def test_two_readers_that_disagree_about_the_frame_are_refused(): - narrow = {**CAPPED, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} - narrow = {**narrow, 'constraints': {'capped': {'dims': ['bus'], 'expression': 'injection <= 10'}}} - with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): - merge({'balance': BALANCE, 'capped': narrow, 'fleet': FLEET}) - - -def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_by_merge(): - narrow = { - 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, - 'variables': {'slack': {'dims': ['bus']}}, - 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection + slack == 0'}}, - } - with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' over \['bus', 'snapshot'\]"): - merge({'balance': narrow, 'demand': {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}}, 'fleet': FLEET}) - - -@pytest.mark.parametrize( - 'others', - [ - pytest.param({'demand': DEMAND}, id='beside-another-term'), - pytest.param({}, id='as-the-only-term'), - ], -) -def test_a_contributor_that_reads_the_sum_without_the_entry_is_refused(others): - """Alone it reads its own term; composed it would read the sum, so the file would mean two things.""" - reads_itself = {**FLEET, 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}} - with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' and reads it"): - merge({'balance': BALANCE, 'fleet': reads_itself, **others}) - - -def test_a_cased_contributor_is_refused_by_merge(): - cased = { - **DEMAND, - 'expressions': { - 'injection': { - 'dims': ['snapshot', 'bus'], - 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, - 'otherwise': '0', - } - }, - } - assert to_spec(cased), 'nothing in the file alone says it is a term' - with pytest.raises(LanguageError, match=r"'demand' adds a term to 'injection' written as `cases:`"): - merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) - - -def test_a_marked_sum_nothing_adds_to_stays_under_given(): - """Zero would leave the balance a row with no variable, which the language refuses.""" - composed = merge({'balance': BALANCE, 'other': {'dimensions': DIMS}}) - assert composed.program.given.expressions['injection'].additive - - -def test_a_patch_replaces_a_term_rather_than_adding_one(): - """`override` edits what is there; a new term is a new fragment for `merge`.""" - laid = override(DEMAND, {'double': {'expressions': {'injection': '-2 * load'}}}) - assert laid.expressions['injection'].expression == '-2 * load' - - -# --------------------------------------------------------------------------- -# printing -# --------------------------------------------------------------------------- - - -def test_the_reader_s_legend_says_other_files_add_to_it(): - given = to_markdown(BALANCE).split('#### Given')[1] - assert 'a sum other files add terms to' in given - - -def test_the_composed_legend_lists_the_sum_as_any_definition(): - definitions = to_markdown(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE})).split('#### Definitions')[ - 1 - ] - assert '`injection`' in definitions - assert 'add terms to' not in definitions, 'the kept entry is what says the name is a sum, not the definition' - - -@pytest.mark.parametrize('fmt', sorted(FORMATS)) -def test_a_reader_and_a_composition_print_in_every_format(fmt): - assert typeset(BALANCE, fmt), f'{fmt} rendered nothing for the reader' - assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition' - - -def test_the_advice_says_the_files_that_add_terms_complete_the_sum(): - """A marked sum is completed by `merge`, not by a host model, so its note says so.""" - (note,) = [note for note in advice(BALANCE) if note.kind == 'given'] - assert note.subject == 'injection' - assert 'a sum other files add terms to' in note.text - assert 'layered onto' not in note.text, 'no host model is asked for' diff --git a/tests/test_several_files_page.py b/tests/test_several_files_page.py index 63a79de6..6fdae6be 100644 --- a/tests/test_several_files_page.py +++ b/tests/test_several_files_page.py @@ -93,4 +93,4 @@ def test_every_python_step_prints_what_the_page_shows(folder): else: assert printed.getvalue().strip() == (expected or '').strip(), f'step {index} printed otherwise' ran += 1 - assert ran == 5, 'every Python block on the page ran' + assert ran == 6, 'every Python block on the page ran' diff --git a/tests/test_terms.py b/tests/test_terms.py new file mode 100644 index 00000000..92610ca2 --- /dev/null +++ b/tests/test_terms.py @@ -0,0 +1,317 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""A named expression several files add terms to. + +A balance reads what every component puts into a bus, and a component file +says what it puts there: a `term:` on its `given: expressions:` entry. The file +reads the name as the whole sum, alone and composed. `merge` defines the name +as the definition one fragment writes, if any, plus every term, so nothing +has to declare that the name is a sum. +""" + +from __future__ import annotations + +import pytest + +import mathspec as ms +from mathspec import ( + FORMATS, + LanguageError, + advice, + merge, + override, + to_markdown, + to_spec, + typeset, + typeset_declaration, +) +from mathspec.program import Variable, walk + +DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} +FRAME = ['snapshot', 'bus'] +INJECTION = 'what the components put into a bus' + +#: The balance: it reads `injection` over its frame, and adds nothing to it. +BALANCE = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': FRAME, 'description': INJECTION}}}, + 'constraints': {'balance': {'dims': FRAME, 'expression': 'injection == 0'}}, +} + +#: A generator fleet: what it puts in is its term. +FLEET = { + 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, + 'given': { + 'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}} + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + +#: A demand, subtracting what it draws. +DEMAND = { + 'dimensions': DIMS, + 'parameters': {'load': {'dims': FRAME}}, + 'given': {'expressions': {'injection': {'dims': FRAME, 'term': '-load'}}}, +} + +#: A store, added in a second merge. +STORAGE = { + 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, + 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, + 'given': { + 'expressions': {'injection': {'dims': FRAME, 'term': 'sum(store_p, by=store_bus, over=store, into=bus)'}} + }, +} + +#: A network that defines the injection itself, as its slack, and reads it. +NETWORK = { + 'dimensions': DIMS, + 'variables': {'slack': {'dims': FRAME}}, + 'expressions': {'injection': {'expression': 'slack', 'description': 'the slack, and what the components add'}}, + 'constraints': {'balance': {'dims': FRAME, 'expression': 'injection == 0'}}, +} + + +def _given(**fields: object) -> dict[str, object]: + """A demand whose `injection` entry says *fields*.""" + return {**DEMAND, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': '-load', **fields}}}} + + +# --------------------------------------------------------------------------- +# one file +# --------------------------------------------------------------------------- + + +def test_a_contributor_loads_alone_and_carries_its_term_resolved(): + program = to_spec(FLEET).program + term = program.given.expressions['injection'].term + assert term is not None + assert any(isinstance(node, Variable) and node.name == 'gen_p' for node in walk(term)), 'resolved in its own file' + assert not program.expressions, 'a term is not a definition' + + +def test_a_contributor_reads_the_name_as_the_whole_sum(): + """Alone and composed the file reads one thing, so nothing has to refuse a file that adds and reads.""" + reads = {**FLEET, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + assert to_spec(reads).program.constraints['capped'].dims == ('snapshot', 'bus') + composed = merge({'balance': BALANCE, 'fleet': reads, 'demand': DEMAND}) + assert composed.constraints['capped'].expression == 'injection <= 10' + assert composed.program.expressions['injection'].in_math, 'composed, the cap reads the sum of every term' + + +@pytest.mark.parametrize( + ('spec', 'message'), + [ + pytest.param( + _given(dims=['bus']), + r"Given expression 'injection': its term carries \['snapshot'\], which its dims \['bus'\] do not", + id='a-term-wider-than-the-entry', + ), + pytest.param( + _given(term='injection - load'), + r"Given expression 'injection': its term reads 'injection', the sum the term adds to", + id='a-term-reading-the-sum', + ), + pytest.param( + _given(term='-lod'), + r"Given expression 'injection': 'lod' not found", + id='a-term-that-does-not-load', + ), + pytest.param( + {**FLEET, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p * gen_p * gen_p)'}}}}, + r"Given expression 'injection'.*degree", + id='a-term-of-degree-three', + ), + pytest.param( + {**DEMAND, 'expressions': {'injection': '0'}}, + r"Given expression 'injection' collides with the named expression", + id='a-term-beside-a-definition', + ), + ], +) +def test_what_a_term_may_not_be_is_refused_at_load(spec, message): + with pytest.raises(LanguageError, match=message): + to_spec(spec) + + +def test_a_term_may_be_quadratic(): + """A term is held to what an objective or a constraint admits, since one of them reads the sum.""" + square = {**FLEET, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p * gen_p)'}}}} + assert to_spec(square).program.given.expressions['injection'].term is not None + + +def test_the_advice_says_the_file_adds_a_term(): + (note,) = [note for note in advice(DEMAND) if note.kind == 'given'] + assert note.subject == 'injection' + assert 'adds a term to it' in note.text + assert 'merge()' in note.text, 'merge completes it, not a host model' + + +def test_the_canonical_form_normalises_a_term(): + one, other = ( + {**DEMAND, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': text}}}} + for text in ('load + load', 'load + load') + ) + other['given']['expressions']['injection']['term'] = '(load) + (load)' + assert ms.to_spec(one).to_yaml(canonical=True) == ms.to_spec(other).to_yaml(canonical=True) + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +def test_merging_defines_the_name_as_the_terms_in_fragment_name_order(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.expressions['injection'].expression == ( + '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert not composed.given, 'every reading is folded into the definition' + assert not composed.program.given + + +def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): + one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + assert one == other + + +def test_a_term_is_added_to_the_definition_one_fragment_writes(): + composed = merge({'network': NETWORK, 'fleet': FLEET, 'demand': DEMAND}) + assert composed.expressions['injection'].expression == ( + '(slack) + (-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert composed.expressions['injection'].description == 'the slack, and what the components add', ( + 'the definition keeps its own description' + ) + + +def test_the_sum_takes_the_readers_description(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == INJECTION + + +def test_a_composed_spec_takes_more_terms_in_a_second_merge(): + """A composed definition is one a fragment wrote, so a later term adds to it like any other.""" + shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + extended = merge({'shipped': shipped, 'storage': STORAGE}) + assert extended.expressions['injection'].expression.startswith('((-load) + (sum(gen_p') + assert 'store_p' in extended.expressions['injection'].expression + + +def test_terms_alone_define_a_name_nothing_reads(): + composed = merge({'fleet': FLEET, 'demand': DEMAND}) + assert composed.expressions['injection'].expression == ( + '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert not composed.program.expressions['injection'].in_math + + +def test_one_term_alone_is_carried_as_written(): + composed = merge({'balance': BALANCE, 'storage': STORAGE}) + assert composed.expressions['injection'].expression == 'sum(store_p, by=store_bus, over=store, into=bus)' + + +def test_two_definitions_collide_and_the_message_names_the_term(): + other = { + 'dimensions': DIMS, + 'variables': {'other_slack': {'dims': FRAME}}, + 'expressions': {'injection': 'other_slack'}, + 'constraints': {'other_balance': {'dims': FRAME, 'expression': 'injection == 0'}}, + } + with pytest.raises(LanguageError) as raised: + merge({'network': NETWORK, 'other': other}) + message = str(raised.value) + assert "both declare the expression 'injection'" in message + assert '`term:` under `given: expressions:`' in message + + +def test_a_cased_definition_a_term_adds_to_is_refused(): + cased = { + **NETWORK, + 'parameters': {'on': {'dims': FRAME, 'dtype': 'bool'}}, + 'expressions': { + 'injection': {'dims': FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} + }, + } + with pytest.raises(LanguageError, match=r"'cased' defines 'injection' as `cases:`, and fragment 'demand' adds"): + merge({'cased': cased, 'demand': DEMAND}) + + +def test_two_readers_that_disagree_about_the_frame_are_refused(): + narrow = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} + with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): + merge({'balance': narrow, 'fleet': FLEET}) + + +def test_a_term_over_fewer_dimensions_merges_where_another_carries_the_rest(): + flat = {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}} + composed = merge({'balance': BALANCE, 'demand': flat, 'fleet': FLEET}) + assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): + wide = { + **NETWORK, + 'dimensions': {**DIMS, 'carrier': {'dtype': 'str'}}, + 'variables': {'slack': {'dims': [*FRAME, 'carrier']}}, + } + wide = {**wide, 'constraints': {'balance': {'dims': [*FRAME, 'carrier'], 'expression': 'injection == 0'}}} + with pytest.raises( + LanguageError, match=r"'demand' reads the given expression 'injection' over \['bus', 'snapshot'\]" + ): + merge({'network': wide, 'demand': DEMAND}) + + +def test_a_patch_replaces_a_term_and_null_drops_it(): + doubled = override(DEMAND, {'double': {'given': {'expressions': {'injection': {'term': '-2 * load'}}}}}) + assert doubled.given.expressions['injection'].term == '-2 * load' + reader = override(DEMAND, {'quiet': {'given': {'expressions': {'injection': {'term': None}}}}}) + assert reader.given.expressions['injection'].term is None, 'the entry is a plain reading again' + + +# --------------------------------------------------------------------------- +# printing +# --------------------------------------------------------------------------- + + +def test_the_legend_says_the_file_adds_a_term(): + given = to_markdown(DEMAND).split('#### Given')[1] + assert 'an expression this file adds a term to' in given + + +def test_the_term_prints_after_dots_that_stand_for_the_other_files(): + assert typeset_declaration(FLEET, 'injection', 'latex') == ( + r'\mathit{injection}_{t,b} = \cdots + \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} ' + r'\mathit{gen\_p}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' + ) + + +def test_a_negated_term_prints_as_a_subtraction(): + assert typeset_declaration(DEMAND, 'injection', 'typst') == ( + 'italic("injection")_(t,b) = dots.c - upright("load")_(t,b) quad forall t in cal(T), b in cal(B)' + ) + + +def test_a_reading_with_no_term_still_prints_no_line_of_its_own(): + with pytest.raises( + LanguageError, match=r"'injection' is a given expression, and a given declaration prints no line" + ): + typeset_declaration(BALANCE, 'injection', 'latex') + + +def test_the_term_prints_under_definitions(): + definitions = to_markdown(DEMAND, legend=False).split('#### Definitions')[1] + assert r'\cdots - \mathrm{load}' in definitions + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_contributor_and_a_composition_print_in_every_format(fmt): + assert typeset(DEMAND, fmt), f'{fmt} rendered nothing for the contributor' + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c69496c8..eb124ed8 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -56,6 +56,11 @@ \item[{$\mathit{warm}$}] \texttt{warm} over $\mathcal{T} \times \mathcal{G}$ \end{description} +\paragraph{Given} +\begin{description} +\item[{$\mathit{withdrawal}$}] \texttt{withdrawal} over $\mathcal{T} \times \mathcal{G}$, an expression this file adds a term to +\end{description} + \paragraph{Definitions} \begin{description} \item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ @@ -145,7 +150,8 @@ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ -\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{withdrawal} && \mathit{withdrawal}_{t,g} & = \cdots - \mathrm{eta}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \end{align} \paragraph{Variable domains} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 9d724f0f..7ea81aa0 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -54,6 +54,12 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{withdrawal}`$ | `withdrawal` over $`\mathcal{T} \times \mathcal{G}`$, an expression this file adds a term to | + #### Definitions | Symbol | Meaning | @@ -424,6 +430,12 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +**`withdrawal`** + +```math +\mathit{withdrawal}_{t,g} = \cdots - \mathrm{eta}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + #### Variable domains **`p`** diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index ebe77903..c3280f33 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -138,6 +138,12 @@ piecewise: - [p, bp_x] - [heat, bp_heat, "<="] +given: + expressions: + withdrawal: # a term this file adds to a name another file defines, which prints after dots + dims: [snapshot, generator] + term: -eta + expressions: spend_cap: cost * 2 # a data-only entry, so a where may compare it spend: # a plain named expression: its symbol prints where it is used, its body once as a definition diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index cd926e14..fd1ed5c9 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -46,6 +46,9 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("op_cost")$: `op_cost` over $cal(T) times cal(G)$ / $italic("warm")$: `warm` over $cal(T) times cal(G)$ +== Given +/ $italic("withdrawal")$: `withdrawal` over $cal(T) times cal(G)$, an expression this file adds a term to + == Definitions / $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs @@ -131,7 +134,8 @@ $ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ - upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ + upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) \ + upright("withdrawal") & italic("withdrawal")_(t,g) & = dots.c - upright("eta")_(g) & forall t in cal(T), g in cal(G) $ == Variable domains #set math.equation(numbering: "(1)") diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index f3c4a0a6..b6eb3194 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -71,9 +71,12 @@ def test_check_prints_nothing_for_a_clean_file(capsys): def test_check_accepts_the_model_that_carries_every_construct(capsys): """The golden model exercises every operator and every edge policy, so `check` accepting it is the claim that the whole language loads through - one door — and that none of it draws advice.""" + one door — and that only the term it adds to another file's name draws advice.""" assert front.main(['check', str(golden.MODEL)]) == 0, 'the whole language loads' - assert capsys.readouterr() == ('', ''), 'no advice, no output' + out, err = capsys.readouterr() + assert err == '' + assert out.startswith("expression 'withdrawal' is read here and declared elsewhere, and this file adds a term") + assert out.count('\n') == 1, 'one note, one line, and nothing else on the model draws advice' def test_check_reads_a_curve_as_written(capsys): diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index 321fd3fa..a9791fca 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -142,7 +142,7 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ pytest.param( PLAIN, 'spent', - r"'spent' is not a named expression, constraint, assumption, curve or variable.*spend", + r"'spent' is not a named expression, term, constraint, assumption, curve or variable.*spend", id='a-near-miss', ), pytest.param(PLAIN, 'objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index c9d98895..8d101f18 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -533,7 +533,7 @@ def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" schema = to_spec(golden.MODEL) - computed = set(schema.variables) | chosen_expressions(schema.program) + computed = set(schema.variables) | set(schema.given.expressions) | chosen_expressions(schema.program) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( f'{sorted(italic - computed)} print italic and are neither chosen by the solver nor read off its ' From d587b528fff062536d632d10183c650561e912af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:14:50 +0000 Subject: [PATCH 57/75] Walk the loaded fragments by value Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- docs/howto/compose.md | 24 +++++------ docs/reference/language/declarations.md | 16 +++++--- docs/several-files.md | 11 ++--- src/mathspec/composition.py | 54 +++++++++++++++++++++++-- tests/test_terms.py | 34 +++++++++++++--- 5 files changed, 105 insertions(+), 34 deletions(-) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 592b8730..2651b384 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term in fragment-name order, each in parentheses | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term in fragment-name order, each in parentheses. A term that lands on a name no fragment defines, reads or uses is refused | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 65efc0e6..05fd64db 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -289,12 +289,16 @@ entry under _Given_, and the math prints the term under _Definitions_ as [`merge`](../../howto/compose.md#a-library-of-components) defines the name as the definition one fragment writes under `expressions:`, if any, plus every term, each in parentheses, in fragment-name order. Nothing declares that the -name is a sum: a term adds to whatever the other files define, and a later -merge adds to the composed definition the same way. A definition written as -`cases:` is refused: name the cased body as its own expression, and define the -name as that name. The definition keeps its own description, or takes the -first a reader wrote. Two files that both define the name under `expressions:` -are refused as a collision, and the message names `term:`. +name is a sum: a term adds to whatever the other files define, as a +fragment's objective adds to the objective, and a later merge adds to the +composed definition the same way. A term has to land on a name another file +has: one that defines it, reads it with no term of its own, or uses it in its +math. Terms alone are refused, with the near miss named, since `merge` fills +a reading or extends a definition and never invents a name. A definition +written as `cases:` is refused: name the cased body as its own expression, +and define the name as that name. The definition keeps its own description, +or takes the first a reader wrote. Two files that both define the name under +`expressions:` are refused as a collision, and the message names `term:`. ## `constraints` diff --git a/docs/several-files.md b/docs/several-files.md index 09774688..357ec6eb 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -336,17 +336,14 @@ False Merge the generators and the loads without the network: ```python -spec = ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) -print(spec.expressions['injection'].expression) -print(sorted(spec.constraints)) +ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) ``` -`merge` accepts it. The two terms still sum to a definition, and no -constraint reads it, because the balance is the network's: +`merge` refuses it. A term adds to a name another file has, and without the +network no file defines, reads or uses `injection`: ```text -(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (-demand) -[] +fragments 'generators' and 'loads' add a term to 'injection', which no fragment defines, reads or uses. A term adds to a name another file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling. ``` ## Where to next diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 2b3a218e..7fb41dc4 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -30,7 +30,9 @@ spec defines the name as the definition one fragment writes, if any, plus every term, each in parentheses, in the fragments' name order. A definition written as ``cases:`` is refused, since the terms are summed as written. A - later merge adds to the composed definition the same way. + later merge adds to the composed definition the same way. Terms that land + on a name no fragment defines, reads or uses are refused: merge fills a + reading or extends a definition, and never invents a name. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, @@ -82,6 +84,7 @@ from mathspec._yaml import read_spec from mathspec.dimensions import dims_of from mathspec.errors import LanguageError, did_you_mean, schema_error +from mathspec.program import Variable, walk from mathspec.spec import GivenBlock, Spec from mathspec.validation import to_spec @@ -162,7 +165,7 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des for section in OWNED_SECTIONS: if claimed := _claimed(read, section): merged[section] = claimed - if summed := _summed(read, _mapping(merged.get('expressions')), readings): + if summed := _summed(read, loaded, _mapping(merged.get('expressions')), readings): merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} if given := _folded(read, merged, loaded, readings): merged['given'] = given @@ -284,7 +287,10 @@ def _terms(read: Mapping[str, dict[str, object]], key: str) -> list[tuple[str, s def _summed( - read: Mapping[str, dict[str, object]], defined: Mapping[str, object], readings: Mapping[str, dict[str, object]] + read: Mapping[str, dict[str, object]], + loaded: Mapping[str, Spec], + defined: Mapping[str, object], + readings: Mapping[str, dict[str, object]], ) -> dict[str, object]: """Every name a fragment adds a term to, defined as the definition plus the terms. @@ -299,6 +305,7 @@ def _summed( terms = _terms(read, key) if not terms: continue + _landed(read, loaded, defined, key, [name for name, _ in terms]) bodies = [term for _, term in terms] block: dict[str, object] = {} if key in defined: @@ -319,6 +326,47 @@ def _summed( return summed +def _landed( + read: Mapping[str, dict[str, object]], + loaded: Mapping[str, Spec], + defined: Mapping[str, object], + key: str, + contributors: list[str], +) -> None: + """Refuse terms that land on a name no fragment owns. + + A term adds to a name another file has: a definition under + ``expressions:``, a reading under ``given:`` with no term of its own, or a + use in its math. Terms alone would define a name nothing asked for, which + is what a mistyped name looks like, so the refusal names the near miss. + """ + if key in defined: + return + for spec in loaded.values(): + reading = spec.given.expressions.get(key) + if reading is not None and reading.term is None: + return + program = spec.program + bodies = (entry.expression for entry in program.expressions.values()) + if any(isinstance(node, Variable) and node.name == key for node in walk(*program.roots, *bodies)): + return + known = { + name + for sections in read.values() + for name in ( + *_mapping(sections.get('expressions')), + *_mapping(_mapping(sections.get('given')).get('expressions')), + ) + } - {key} + spelled = ', '.join(f"'{name}'" for name in contributors[:-1]) + who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" + near = f' {did_you_mean(key, known)}' if known else '' + raise LanguageError( + f'{who} a term to {key!r}, which no fragment defines, reads or uses. A term adds to a name another ' + f"file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling.{near}" + ) + + def _as_mapping(block: object) -> dict[str, object]: """A named expression as ``to_dict`` wrote it, the one-line form read as its mapping.""" return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} diff --git a/tests/test_terms.py b/tests/test_terms.py index 92610ca2..012bb04f 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -204,12 +204,34 @@ def test_a_composed_spec_takes_more_terms_in_a_second_merge(): assert 'store_p' in extended.expressions['injection'].expression -def test_terms_alone_define_a_name_nothing_reads(): - composed = merge({'fleet': FLEET, 'demand': DEMAND}) - assert composed.expressions['injection'].expression == ( - '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' - ) - assert not composed.program.expressions['injection'].in_math +@pytest.mark.parametrize( + ('fragments', 'message'), + [ + pytest.param( + {'fleet': FLEET, 'demand': DEMAND}, + r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines, reads or uses", + id='terms-and-nothing-else', + ), + pytest.param( + { + 'balance': BALANCE, + 'fleet': {**FLEET, 'given': {'expressions': {'injecton': FLEET['given']['expressions']['injection']}}}, + }, + r"fragment 'fleet' adds a term to 'injecton', which no fragment.*Did you mean 'injection'\?", + id='a-mistyped-name', + ), + ], +) +def test_terms_that_land_on_no_name_are_refused(fragments, message): + """Merge fills a reading or extends a definition; it never invents a name, which is what a typo would ask for.""" + with pytest.raises(LanguageError, match=message): + merge(fragments) + + +def test_a_term_lands_on_a_name_a_contributor_s_own_math_uses(): + capped = {**FLEET, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + composed = merge({'fleet': capped, 'demand': DEMAND}) + assert composed.program.expressions['injection'].in_math def test_one_term_alone_is_carried_as_written(): From 19754386efd55918f36fedf060ae52377578944d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:08:54 +0000 Subject: [PATCH 58/75] A term names an expression of its file, and merge adds it by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `term:` on a `given: expressions:` entry now names a named expression the same file declares, and no longer takes an expression written inline. The term is then free: it takes `cases:`, a description and every rule of a named expression, and it counts as read by the math. `merge` adds the terms by name, without brackets around a name, and keeps each term as a named expression of the composed spec. A refusal for terms that land on no name names only a near miss. The typeset term line is `name = ⋯ + term`. The tutorial, the reference, the how-to, the golden model and the notation page follow. The #734 line leaves the changelog; #732 carries the design. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 1 - docs/howto/compose.md | 24 ++-- docs/reference/language/declarations.md | 41 +++--- docs/reference/notation.md | 14 ++ docs/reference/reading.md | 5 +- docs/several-files.md | 67 +++++++--- schema/mathspec.schema.json | 2 +- src/mathspec/canonical.py | 2 +- src/mathspec/composition.py | 34 +++-- src/mathspec/lowering.py | 14 +- src/mathspec/program.py | 9 +- src/mathspec/spec.py | 12 +- src/mathspec/typesetting/walk.py | 13 +- tests/test_terms.py | 168 +++++++++++++++--------- tests/typesetting/golden/latex.out | 4 +- tests/typesetting/golden/markdown.out | 9 +- tests/typesetting/golden/model.yaml | 5 +- tests/typesetting/golden/typst.out | 6 +- tools/notation.py | 1 + 19 files changed, 279 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4729d1a..d54c853a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) -- feat(language): a file adds a term to an expression another file defines, under `given:` ([#734](https://github.com/energy-models/mathspec/pull/734)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) ## 0.2.0 (2026-09-25) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 2651b384..42fbc183 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term in fragment-name order, each in parentheses. A term that lands on a name no fragment defines, reads or uses is refused | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that lands on a name no fragment defines, reads or uses is refused | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 05fd64db..d2acea4f 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -242,8 +242,10 @@ refused once folded. #### A term a file adds -`term:` is what this file adds to the name. The file reads the name as the -whole sum, alone and composed, and the term is its part of it. A file that +`term:` names the expression this file adds to the name. The term is an +ordinary named expression of this file, so it takes `cases:`, a description +and every other field a named expression takes. The file reads the name as +the whole sum, alone and composed, and the term is its part of it. A file that only reads the name writes no term. ```yaml @@ -256,11 +258,15 @@ relations: gen_bus: { key: generator, values: bus } variables: gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +expressions: + generation: + description: what the fleet puts into a bus + expression: sum(gen_p, by=gen_bus, over=generator, into=bus) given: expressions: injection: dims: [snapshot, bus] - term: sum(gen_p, by=gen_bus, over=generator, into=bus) + term: generation ``` ```yaml @@ -279,24 +285,29 @@ constraints: expression: injection == 0 ``` -Each file loads alone. A term is one expression, resolved in its own file: it -reads what the file declares, and not the name it adds to. It carries no -dimension the entry does not state, and it is held to degree two, as what -reads the sum is. All three are checked at load. The typeset legend lists the -entry under _Given_, and the math prints the term under _Definitions_ as -`injection = ⋯ + term`, the dots standing for what the other files add. +Each file loads alone. The term names an expression the file declares, and +that expression does not read the name it adds to, directly or through +another name. It carries no dimension the entry does not state, and it is +held to degree two, as what reads the sum is. All of this is checked at load. +The typeset legend lists the entry under _Given_, and the math prints the term +under _Definitions_ as its own line and as `injection = ⋯ + generation`, the +dots standing for what the other files add. [`merge`](../../howto/compose.md#a-library-of-components) defines the name as the definition one fragment writes under `expressions:`, if any, plus every -term, each in parentheses, in fragment-name order. Nothing declares that the -name is a sum: a term adds to whatever the other files define, as a -fragment's objective adds to the objective, and a later merge adds to the -composed definition the same way. A term has to land on a name another file +term by its name, in fragment-name order, and keeps each term as a named +expression of the composed spec. Nothing declares that the name is a sum: a +term adds to whatever the other files define, as a fragment's objective adds +to the objective, and a later merge adds to the composed definition the same +way. The file that defines the name does not opt in. It reads the name as its +own definition alone, and as the definition plus every term once composed; +whoever composes the files answers for that sum. A term has to land on a name another file has: one that defines it, reads it with no term of its own, or uses it in its math. Terms alone are refused, with the near miss named, since `merge` fills a reading or extends a definition and never invents a name. A definition -written as `cases:` is refused: name the cased body as its own expression, -and define the name as that name. The definition keeps its own description, +written as `cases:` is refused, since it is summed as written: name the cased +body as its own expression, and define the name as that name. A cased term is +added like any other, by its name. The definition keeps its own description, or takes the first a reader wrote. Two files that both define the name under `expressions:` are refused as a collision, and the message names `term:`. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index ac28210b..9c7fbc9e 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -127,6 +127,7 @@ parameters: | Symbol | Meaning | |---|---| +| $`\mathrm{leak}`$ | `leak` over $`\mathcal{G}`$ | | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | @@ -738,6 +739,19 @@ expressions: \mathit{marginal\_price}_{t,b} = \lambda_{\mathrm{balance},t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` +#### Term a given expression names + +the term: an ordinary named expression, which a given expression names + +```yaml +expressions: + leak: -eta +``` + +```math +\mathrm{leak}_{g} = -\mathrm{eta}_{g} \qquad \forall\, g \in \mathcal{G} +``` + ### Shifts #### Cyclic and acyclic shift diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 23d18204..4ae78559 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -171,8 +171,9 @@ other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. A given expression with a `term` is one this file adds to: -`program.given.expressions[name].term` is the term, resolved in this program, -and the name is still one the program reads and does not build. +`program.given.expressions[name].term` is the term: the `Named` node of the +entry of `program.expressions` it names. The name is still one the program +reads and does not build. ```python layer = to_spec( diff --git a/docs/several-files.md b/docs/several-files.md index 357ec6eb..68544688 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -48,9 +48,10 @@ expression 'injection' is read here and declared elsewhere: the model this one i ## The generators -Make a file `generators.yaml`. It reads the injection too, and it says what it -puts in: a [`term:`](reference/language/declarations.md#a-term-a-file-adds) -on the entry. +Make a file `generators.yaml`. It says what the fleet puts into a bus as a +named expression, `generation`. It reads the injection too, and a +[`term:`](reference/language/declarations.md#a-term-a-file-adds) on the entry +names `generation` as what this file adds to it. ```yaml title="generators.yaml" description: A generator fleet, each unit on one bus. @@ -73,11 +74,16 @@ variables: dims: [snapshot, generator] bounds: { lower: 0, upper: capacity } +expressions: + generation: + description: what the fleet puts into a bus + expression: sum(dispatch, by=gen_bus, over=generator, into=bus) + given: expressions: injection: dims: [snapshot, bus] - term: sum(dispatch, by=gen_bus, over=generator, into=bus) + term: generation objective: sense: minimize @@ -104,7 +110,8 @@ import mathspec as ms print(ms.to_markdown('generators.yaml', legend=False)) ``` -The term prints after dots, which stand for what the other files put in: +The term prints as its own definition, and then after dots, which stand for +what the other files put in: !!! example "Rendered output" @@ -118,10 +125,16 @@ The term prints after dots, which stand for what the other files put in: #### Definitions + **`generation`** + + ```math + \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + **`injection`** ```math - \mathit{injection}_{t,b} = \cdots + \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{injection}_{t,b} = \cdots + \mathit{generation}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` #### Variable domains @@ -134,7 +147,8 @@ The term prints after dots, which stand for what the other files put in: ## The loads -Make a file `loads.yaml`. Its term takes the demand out of the bus: +Make a file `loads.yaml`. Its term, `consumption`, takes the demand out of the +bus: ```yaml title="loads.yaml" description: The demand at every bus. @@ -146,11 +160,16 @@ dimensions: parameters: demand: { dims: [snapshot, bus], description: demand to be met } +expressions: + consumption: + description: what the loads take out of a bus + expression: -demand + given: expressions: injection: dims: [snapshot, bus] - term: -demand + term: consumption ``` Check the file: @@ -174,10 +193,11 @@ spec = ms.merge({'network': 'network.yaml', 'generators': 'generators.yaml', 'lo print(spec.expressions['injection'].expression) ``` -The injection is the sum of the two terms, in the order of the file names: +The injection is the sum of the two terms by name, in the order of the file +names. Each term stays a named expression of the merged spec: ```text -(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (-demand) +generation + consumption ``` Print the math of the merged spec: @@ -204,10 +224,22 @@ print(ms.to_markdown(spec, legend=False)) #### Definitions + **`generation`** + + ```math + \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + **`consumption`** + + ```math + \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + **`injection`** ```math - \mathit{injection}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} - \mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` #### Variable domains @@ -220,8 +252,8 @@ print(ms.to_markdown(spec, legend=False)) ## Add a component -Make a file `imports.yaml`. It adds a term to the injection and a term to the -objective: +Make a file `imports.yaml`. It adds a term, `purchase`, to the injection and a +term to the objective: ```yaml title="imports.yaml" description: Power bought from outside the network, at a price. @@ -240,11 +272,16 @@ variables: dims: [snapshot, bus] bounds: { lower: 0, upper: import_limit } +expressions: + purchase: + description: what the imports put into a bus + expression: imported + given: expressions: injection: dims: [snapshot, bus] - term: imported + term: purchase objective: sense: minimize @@ -265,7 +302,7 @@ The injection has a third term, and the objective sums the two objectives. `network.yaml` did not change: ```text -(sum(dispatch, by=gen_bus, over=generator, into=bus)) + (imported) + (-demand) +generation + purchase + consumption (sum(dispatch * cost)) + (sum(imported * import_price)) ``` diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index bb83e7f3..634a9d1f 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -342,7 +342,7 @@ }, "GivenExpressionBlock": { "additionalProperties": false, - "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``term:`` is what this file adds to the name. It is one expression, read\nover at most the frame, and [`merge`][mathspec.composition.merge] sums it\nwith the definition another file writes and the terms other files add.\nThe file itself reads the name as the whole sum, alone and composed.", + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``term:`` names the expression this file adds to the name, one this file\ndeclares under ``expressions:`` and reads over at most the frame.\n[`merge`][mathspec.composition.merge] adds it by name to the definition\nanother file writes and to the terms other files add, and keeps it as a\nnamed expression. The file itself reads the name as the whole sum, alone\nand composed.", "properties": { "description": { "anyOf": [ diff --git a/src/mathspec/canonical.py b/src/mathspec/canonical.py index bb0330cd..8198d3a8 100644 --- a/src/mathspec/canonical.py +++ b/src/mathspec/canonical.py @@ -33,7 +33,7 @@ #: plain data. A link is the odd one: it serialises back to the ``[expression, #: values]`` list the file wrote, so its expression is a position rather than a #: key. -_EXPRESSION_KEYS = ('expression', 'otherwise', 'template', 'term') +_EXPRESSION_KEYS = ('expression', 'otherwise', 'template') def _signed(sign: Sign, node: ArithmeticNode) -> tuple[Sign, ArithmeticNode]: diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 7fb41dc4..2ad1f65c 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -26,11 +26,12 @@ * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. * **A term is added to the definition it names.** A ``given: expressions:`` - entry with a ``term:`` is what its fragment adds to the name. The composed - spec defines the name as the definition one fragment writes, if any, plus - every term, each in parentheses, in the fragments' name order. A definition - written as ``cases:`` is refused, since the terms are summed as written. A - later merge adds to the composed definition the same way. Terms that land + entry with a ``term:`` names the expression its fragment adds to the name. + The composed spec defines the name as the definition one fragment writes, + if any, plus every term by its name, in the fragments' name order, and keeps + each term as the named expression its fragment declares. A definition + written as ``cases:`` is refused, since it is summed as written. A later + merge adds to the composed definition the same way. Terms that land on a name no fragment defines, reads or uses are refused: merge fills a reading or extends a definition, and never invents a name. * **A given declaration is folded** into the declaration that introduces the @@ -75,6 +76,7 @@ from __future__ import annotations +import difflib from collections.abc import Mapping from copy import deepcopy from typing import TYPE_CHECKING, cast, get_args, get_origin @@ -294,11 +296,12 @@ def _summed( ) -> dict[str, object]: """Every name a fragment adds a term to, defined as the definition plus the terms. - The definition one fragment writes comes first, then every term in the - fragments' name order, each in parentheses; one body alone is carried as - written. A definition written as ``cases:`` is refused, since the terms - are summed as written and a set of cases is no one body. The definition - keeps its own description, or takes the first a reader wrote. + The definition one fragment writes comes first, in parentheses where it + is more than a name, then every term by its name in the fragments' name + order; one body alone is carried as written. A definition written as + ``cases:`` is refused, since it is summed as written and a set of cases is + no one body. The definition keeps its own description, or takes the first + a reader wrote. """ summed: dict[str, object] = {} for key, entry in readings.items(): @@ -313,13 +316,13 @@ def _summed( if base.get('cases'): raise LanguageError( f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " - f"'{terms[0][0]}' adds a term to it. The terms are summed as written, and a set of cases is no " + f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " f'one body: name the cased body as its own expression, and define {key!r} as that name.' ) bodies.insert(0, cast('str', base['expression'])) if base.get('description'): block['description'] = base['description'] - block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) + block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(_summand(body) for body in bodies) if 'description' not in block and entry.get('description'): block['description'] = entry['description'] summed[key] = block @@ -360,13 +363,18 @@ def _landed( } - {key} spelled = ', '.join(f"'{name}'" for name in contributors[:-1]) who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" - near = f' {did_you_mean(key, known)}' if known else '' + near = ''.join(f" Did you mean '{name}'?" for name in difflib.get_close_matches(key, sorted(known), n=1)) raise LanguageError( f'{who} a term to {key!r}, which no fragment defines, reads or uses. A term adds to a name another ' f"file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling.{near}" ) +def _summand(body: str) -> str: + """*body* as one operand of a sum: a term is a name and needs no brackets, and a definition may.""" + return body if body.isidentifier() else f'({body})' + + def _as_mapping(block: object) -> dict[str, object]: """A named expression as ``to_dict`` wrote it, the one-line form read as its mapping.""" return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 714786b3..611664db 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING from mathspec.dimensions import check_schema, dims_of -from mathspec.errors import SchemaError, prefixed +from mathspec.errors import SchemaError, did_you_mean, prefixed from mathspec.expansion import expand, parse_template from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from mathspec.program import ( @@ -130,13 +130,20 @@ def lower(schema: Spec) -> Program: if gdef.term is None: continue context = f"Given expression '{gname}'" + if gdef.term not in schema.expressions: + errors.append( + f'{context}: its term {gdef.term!r} is no expression this file declares. A term is a named ' + f"expression: declare it under 'expressions:', and write its name here. " + f'{did_you_mean(gdef.term, schema.expressions)}' + ) + continue term = resolve_expression_text(gdef.term, ns, context, errors, ceiling=2) if term is None: continue if any(isinstance(node, Variable) and node.name == gname for node in walk(term)): errors.append( - f"{context}: its term reads '{gname}', the sum the term adds to, so the sum would define itself. " - f'A term is what this file puts in: write it in what this file declares.' + f"{context}: its term {gdef.term!r} reads '{gname}', the sum the term adds to, so the sum would " + f'define itself. A term is what this file puts in: write it in what this file declares.' ) continue terms[gname] = term @@ -195,6 +202,7 @@ def lower(schema: Spec) -> Program: if objective is not None: roots.append(objective.expression) roots.extend(link for links in curves.values() for link in links) + roots.extend(terms.values()) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} diff --git a/src/mathspec/program.py b/src/mathspec/program.py index aecedd70..24c1ba67 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -623,8 +623,8 @@ class GivenDeclaration: dims: tuple[str, ...] description: str | None = None #: The term this program adds to a given expression, or ``None`` where it - #: only reads the name. Resolved in this program's namespace, and read - #: over at most ``dims``. + #: only reads the name: the [`Named`][] node of the entry the term names, + #: read over at most ``dims``. term: Expression | None = None @@ -703,8 +703,9 @@ class ObjectiveDeclaration: class ExpressionDeclaration: """A named quantity — one the math reads, or one only read back after a solve. - ``in_math`` where the objective or a constraint reads it, directly or - through another entry or a macro; its body then stands inside + ``in_math`` where the objective, a constraint or a term this program + adds to a given expression reads it, directly or through another entry or + a macro; its body then stands inside [`Program.roots`][] and is held to the degree rules where it is read. Otherwise nothing a solver sees contains it: it is a reported quantity, its body held to no degree, the one place a [`Dual`][] may diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 5b37d3f8..93aebc1a 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -334,16 +334,18 @@ class GivenExpressionBlock(_StrictBlock): body is the definer's, and the composed spec holds the body to the rules of every place this file reads it. - ``term:`` is what this file adds to the name. It is one expression, read - over at most the frame, and [`merge`][mathspec.composition.merge] sums it - with the definition another file writes and the terms other files add. - The file itself reads the name as the whole sum, alone and composed. + ``term:`` names the expression this file adds to the name, one this file + declares under ``expressions:`` and reads over at most the frame. + [`merge`][mathspec.composition.merge] adds it by name to the definition + another file writes and to the terms other files add, and keeps it as a + named expression. The file itself reads the name as the whole sum, alone + and composed. """ _label: ClassVar[str] = 'a given expression declaration' dims: list[str] - #: The term this file adds to the name, or ``None`` where it only reads it. + #: The named expression this file adds to the name, or ``None`` where it only reads it. term: str | None = None description: str | None = None diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index fb413f73..435282e8 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -713,24 +713,17 @@ def terms(self) -> list[str]: def term(self, name: str) -> Line: """The line for the term this program adds to a given expression: ``symbol = ⋯ + term`` over the entry's frame. - The dots stand for what the other files put in. A negated term prints - as a subtraction, and is bracketed where [`_binary`][] would bracket - the right operand of one. + The dots stand for what the other files put in. """ given = self.program.given.expressions[name] assert given.term is not None, 'a line prints for a term, and not for a name this program only reads' frame = list(given.dims) ctx = self._context(frame) - operand: Expression = given.term - op: BinaryOperator = '+' - while (unsigned := _unsigned(operand)) is not None: - operand, op = unsigned, '-' if op == '+' else '+' - rendered = self._expression(operand, ctx, need=_PRECEDENCE[op] + (1 if op == '-' else 0)) - sign = self._op('plus' if op == '+' else 'minus') + rendered = self._expression(given.term, ctx, need=_PRECEDENCE['+']) return Line( label=name, left=ctx.indexed(self.symbols.name[name], frame), - right=f'{self._op("equal")} {self._op("ellipsis")} {sign} {rendered}', + right=f'{self._op("equal")} {self._op("ellipsis")} {self._op("plus")} {rendered}', condition=self._quantifier(frame, ''), ) diff --git a/tests/test_terms.py b/tests/test_terms.py index 012bb04f..82d4e07d 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -5,17 +5,17 @@ """A named expression several files add terms to. A balance reads what every component puts into a bus, and a component file -says what it puts there: a `term:` on its `given: expressions:` entry. The file -reads the name as the whole sum, alone and composed. `merge` defines the name -as the definition one fragment writes, if any, plus every term, so nothing -has to declare that the name is a sum. +says what it puts there: a named expression of its own, which the `term:` on +its `given: expressions:` entry names. The file reads the name as the whole +sum, alone and composed. `merge` defines the name as the definition one +fragment writes, if any, plus every term by name, and keeps each term, so +nothing has to declare that the name is a sum. """ from __future__ import annotations import pytest -import mathspec as ms from mathspec import ( FORMATS, LanguageError, @@ -27,7 +27,7 @@ typeset, typeset_declaration, ) -from mathspec.program import Variable, walk +from mathspec.program import Named, Variable, walk DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} FRAME = ['snapshot', 'bus'] @@ -45,8 +45,12 @@ 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, - 'given': { - 'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}} + 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'generator_injection'}}}, + 'expressions': { + 'generator_injection': { + 'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', + 'description': 'what the generators put in', + } }, 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, } @@ -55,7 +59,8 @@ DEMAND = { 'dimensions': DIMS, 'parameters': {'load': {'dims': FRAME}}, - 'given': {'expressions': {'injection': {'dims': FRAME, 'term': '-load'}}}, + 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'demand_injection'}}}, + 'expressions': {'demand_injection': '-load'}, } #: A store, added in a second merge. @@ -63,9 +68,8 @@ 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, - 'given': { - 'expressions': {'injection': {'dims': FRAME, 'term': 'sum(store_p, by=store_bus, over=store, into=bus)'}} - }, + 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'store_injection'}}}, + 'expressions': {'store_injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, } #: A network that defines the injection itself, as its slack, and reads it. @@ -77,9 +81,13 @@ } -def _given(**fields: object) -> dict[str, object]: - """A demand whose `injection` entry says *fields*.""" - return {**DEMAND, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': '-load', **fields}}}} +def _demand(term: str = 'demand_injection', body: object = '-load', **fields: object) -> dict[str, object]: + """A demand whose `injection` entry names *term*, with *body* as `demand_injection` and *fields* on the entry.""" + return { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': FRAME, 'term': term, **fields}}}, + 'expressions': {'demand_injection': body}, + } # --------------------------------------------------------------------------- @@ -87,12 +95,17 @@ def _given(**fields: object) -> dict[str, object]: # --------------------------------------------------------------------------- -def test_a_contributor_loads_alone_and_carries_its_term_resolved(): +def test_a_contributor_loads_alone_and_its_term_is_its_named_expression(): program = to_spec(FLEET).program term = program.given.expressions['injection'].term - assert term is not None + assert isinstance(term, Named) + assert term.name == 'generator_injection' assert any(isinstance(node, Variable) and node.name == 'gen_p' for node in walk(term)), 'resolved in its own file' - assert not program.expressions, 'a term is not a definition' + + +def test_a_term_is_read_by_the_math(): + """The sum it lands in is read by a constraint, so it is held to what the math admits.""" + assert to_spec(FLEET).program.expressions['generator_injection'].in_math def test_a_contributor_reads_the_name_as_the_whole_sum(): @@ -108,27 +121,32 @@ def test_a_contributor_reads_the_name_as_the_whole_sum(): ('spec', 'message'), [ pytest.param( - _given(dims=['bus']), + _demand(dims=['bus']), r"Given expression 'injection': its term carries \['snapshot'\], which its dims \['bus'\] do not", id='a-term-wider-than-the-entry', ), pytest.param( - _given(term='injection - load'), - r"Given expression 'injection': its term reads 'injection', the sum the term adds to", - id='a-term-reading-the-sum', + _demand(body='injection - load'), + r"Given expression 'injection': its term 'demand_injection' reads 'injection', the sum the term adds to", + id='a-term-reading-the-sum-through-its-name', ), pytest.param( - _given(term='-lod'), - r"Given expression 'injection': 'lod' not found", - id='a-term-that-does-not-load', + _demand(term='-load'), + r"Given expression 'injection': its term '-load' is no expression this file declares", + id='a-term-written-inline', ), pytest.param( - {**FLEET, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p * gen_p * gen_p)'}}}}, + _demand(term='demand_injecton'), + r"its term 'demand_injecton' is no expression.*Did you mean 'demand_injection'\?", + id='a-mistyped-term', + ), + pytest.param( + {**FLEET, 'expressions': {'generator_injection': 'sum(gen_p * gen_p * gen_p, over=generator)'}}, r"Given expression 'injection'.*degree", id='a-term-of-degree-three', ), pytest.param( - {**DEMAND, 'expressions': {'injection': '0'}}, + {**DEMAND, 'expressions': {**DEMAND['expressions'], 'injection': '0'}}, r"Given expression 'injection' collides with the named expression", id='a-term-beside-a-definition', ), @@ -141,7 +159,7 @@ def test_what_a_term_may_not_be_is_refused_at_load(spec, message): def test_a_term_may_be_quadratic(): """A term is held to what an objective or a constraint admits, since one of them reads the sum.""" - square = {**FLEET, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'sum(gen_p * gen_p)'}}}} + square = {**FLEET, 'expressions': {'generator_injection': 'sum(gen_p * gen_p, over=generator)'}} assert to_spec(square).program.given.expressions['injection'].term is not None @@ -152,27 +170,17 @@ def test_the_advice_says_the_file_adds_a_term(): assert 'merge()' in note.text, 'merge completes it, not a host model' -def test_the_canonical_form_normalises_a_term(): - one, other = ( - {**DEMAND, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': text}}}} - for text in ('load + load', 'load + load') - ) - other['given']['expressions']['injection']['term'] = '(load) + (load)' - assert ms.to_spec(one).to_yaml(canonical=True) == ms.to_spec(other).to_yaml(canonical=True) - - # --------------------------------------------------------------------------- # merge # --------------------------------------------------------------------------- -def test_merging_defines_the_name_as_the_terms_in_fragment_name_order(): +def test_merging_adds_the_terms_by_name_in_fragment_name_order_and_keeps_them(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - assert composed.expressions['injection'].expression == ( - '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' - ) + assert composed.expressions['injection'].expression == 'demand_injection + generator_injection' + assert composed.expressions['demand_injection'].expression == '-load', 'each term stays a named expression' + assert composed.program.expressions['generator_injection'].description == 'what the generators put in' assert not composed.given, 'every reading is folded into the definition' - assert not composed.program.given def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): @@ -183,14 +191,26 @@ def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): def test_a_term_is_added_to_the_definition_one_fragment_writes(): composed = merge({'network': NETWORK, 'fleet': FLEET, 'demand': DEMAND}) - assert composed.expressions['injection'].expression == ( - '(slack) + (-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' - ) + assert composed.expressions['injection'].expression == 'slack + demand_injection + generator_injection' assert composed.expressions['injection'].description == 'the slack, and what the components add', ( 'the definition keeps its own description' ) +def test_the_file_that_defines_the_name_reads_the_extended_sum_once_composed(): + """A contributor decides alone. The defining file does not opt in, and whoever composes answers for the sum.""" + assert to_spec(NETWORK).expressions['injection'].expression == 'slack' + composed = merge({'network': NETWORK, 'demand': DEMAND}) + assert composed.constraints['balance'].expression == 'injection == 0' + assert composed.expressions['injection'].expression == 'slack + demand_injection' + + +def test_a_definition_that_is_more_than_a_name_is_bracketed(): + network = {**NETWORK, 'expressions': {'injection': 'slack - slack / 2'}} + composed = merge({'network': network, 'demand': DEMAND}) + assert composed.expressions['injection'].expression == '(slack - slack / 2) + demand_injection' + + def test_the_sum_takes_the_readers_description(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) assert composed.program.expressions['injection'].description == INJECTION @@ -200,8 +220,23 @@ def test_a_composed_spec_takes_more_terms_in_a_second_merge(): """A composed definition is one a fragment wrote, so a later term adds to it like any other.""" shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) extended = merge({'shipped': shipped, 'storage': STORAGE}) - assert extended.expressions['injection'].expression.startswith('((-load) + (sum(gen_p') - assert 'store_p' in extended.expressions['injection'].expression + assert extended.expressions['injection'].expression == ( + '(demand_injection + generator_injection) + store_injection' + ) + + +def test_a_cased_term_is_added_like_any_other(): + """The sum names the term, so how the term is written is its own file's business.""" + cased = _demand( + body={ + 'dims': FRAME, + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + ) + composed = merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) + assert composed.expressions['injection'].expression == 'demand_injection + generator_injection' + assert composed.program.expressions['demand_injection'].in_math @pytest.mark.parametrize( @@ -234,9 +269,9 @@ def test_a_term_lands_on_a_name_a_contributor_s_own_math_uses(): assert composed.program.expressions['injection'].in_math -def test_one_term_alone_is_carried_as_written(): +def test_one_term_alone_is_its_name(): composed = merge({'balance': BALANCE, 'storage': STORAGE}) - assert composed.expressions['injection'].expression == 'sum(store_p, by=store_bus, over=store, into=bus)' + assert composed.expressions['injection'].expression == 'store_injection' def test_two_definitions_collide_and_the_message_names_the_term(): @@ -253,6 +288,14 @@ def test_two_definitions_collide_and_the_message_names_the_term(): assert '`term:` under `given: expressions:`' in message +def test_two_terms_of_one_name_collide(): + """A term is an ordinary named expression, so two fragments name theirs apart.""" + twin = {**FLEET, 'expressions': {'demand_injection': FLEET['expressions']['generator_injection']}} + twin = {**twin, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'demand_injection'}}}} + with pytest.raises(LanguageError, match=r"both declare the expression 'demand_injection'"): + merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': twin}) + + def test_a_cased_definition_a_term_adds_to_is_refused(): cased = { **NETWORK, @@ -291,9 +334,9 @@ def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): merge({'network': wide, 'demand': DEMAND}) -def test_a_patch_replaces_a_term_and_null_drops_it(): - doubled = override(DEMAND, {'double': {'given': {'expressions': {'injection': {'term': '-2 * load'}}}}}) - assert doubled.given.expressions['injection'].term == '-2 * load' +def test_a_patch_changes_a_term_by_its_name_and_null_drops_it(): + doubled = override(DEMAND, {'double': {'expressions': {'demand_injection': '-2 * load'}}}) + assert doubled.expressions['demand_injection'].expression == '-2 * load' reader = override(DEMAND, {'quiet': {'given': {'expressions': {'injection': {'term': None}}}}}) assert reader.given.expressions['injection'].term is None, 'the entry is a plain reading again' @@ -309,15 +352,9 @@ def test_the_legend_says_the_file_adds_a_term(): def test_the_term_prints_after_dots_that_stand_for_the_other_files(): - assert typeset_declaration(FLEET, 'injection', 'latex') == ( - r'\mathit{injection}_{t,b} = \cdots + \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} ' - r'\mathit{gen\_p}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' - ) - - -def test_a_negated_term_prints_as_a_subtraction(): - assert typeset_declaration(DEMAND, 'injection', 'typst') == ( - 'italic("injection")_(t,b) = dots.c - upright("load")_(t,b) quad forall t in cal(T), b in cal(B)' + assert typeset_declaration(FLEET, 'injection', 'latex', inline_expressions=False) == ( + r'\mathit{injection}_{t,b} = \cdots + \mathit{generator\_injection}_{t,b} ' + r'\qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' ) @@ -328,9 +365,12 @@ def test_a_reading_with_no_term_still_prints_no_line_of_its_own(): typeset_declaration(BALANCE, 'injection', 'latex') -def test_the_term_prints_under_definitions(): - definitions = to_markdown(DEMAND, legend=False).split('#### Definitions')[1] - assert r'\cdots - \mathrm{load}' in definitions +def test_the_composed_sum_prints_its_terms_by_name(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert typeset_declaration(composed, 'injection', 'typst', inline_expressions=False) == ( + 'italic("injection")_(t,b) = upright("demand_injection")_(t,b) + italic("generator_injection")_(t,b) ' + 'quad forall t in cal(T), b in cal(B)' + ) @pytest.mark.parametrize('fmt', sorted(FORMATS)) diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index eb124ed8..bb55d1ec 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -63,6 +63,7 @@ \paragraph{Definitions} \begin{description} +\item[{$\mathrm{leak}$}] \texttt{leak} over $\mathcal{G}$ \item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ \item[{$\mathit{spend}$}] \texttt{spend} over $\mathcal{T}$ --- what a snapshot's dispatch costs \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) @@ -146,12 +147,13 @@ \paragraph{Definitions} \begin{align} +\text{leak} && \mathrm{leak}_{g} & = -\mathrm{eta}_{g} && \forall\, g \in \mathcal{G} \\ \text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{withdrawal} && \mathit{withdrawal}_{t,g} & = \cdots - \mathrm{eta}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{withdrawal} && \mathit{withdrawal}_{t,g} & = \cdots + \mathrm{leak}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \end{align} \paragraph{Variable domains} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 7ea81aa0..15c8672e 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -64,6 +64,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | Symbol | Meaning | |---|---| +| $`\mathrm{leak}`$ | `leak` over $`\mathcal{G}`$ | | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | @@ -400,6 +401,12 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g #### Definitions +**`leak`** + +```math +\mathrm{leak}_{g} = -\mathrm{eta}_{g} \qquad \forall\, g \in \mathcal{G} +``` + **`spend_cap`** ```math @@ -433,7 +440,7 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g **`withdrawal`** ```math -\mathit{withdrawal}_{t,g} = \cdots - \mathrm{eta}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\mathit{withdrawal}_{t,g} = \cdots + \mathrm{leak}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` #### Variable domains diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index c3280f33..921693af 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -140,11 +140,12 @@ piecewise: given: expressions: - withdrawal: # a term this file adds to a name another file defines, which prints after dots + withdrawal: # a name another file defines, which this file adds a term to: the term prints after dots dims: [snapshot, generator] - term: -eta + term: leak expressions: + leak: -eta # the term: an ordinary named expression, which a given expression names spend_cap: cost * 2 # a data-only entry, so a where may compare it spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index fd1ed5c9..3217865f 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -50,6 +50,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("withdrawal")$: `withdrawal` over $cal(T) times cal(G)$, an expression this file adds a term to == Definitions +/ $upright("leak")$: `leak` over $cal(G)$ / $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs / $italic("lcoe")$: `lcoe` (scalar) @@ -130,12 +131,13 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in == Definitions #set math.equation(numbering: "(1)") -$ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ +$ upright("leak") & upright("leak")_(g) & = -upright("eta")_(g) & forall g in cal(G) \ + upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) \ - upright("withdrawal") & italic("withdrawal")_(t,g) & = dots.c - upright("eta")_(g) & forall t in cal(T), g in cal(G) $ + upright("withdrawal") & italic("withdrawal")_(t,g) & = dots.c + upright("leak")_(g) & forall t in cal(T), g in cal(G) $ == Variable domains #set math.equation(numbering: "(1)") diff --git a/tools/notation.py b/tools/notation.py index 50510746..8217e504 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -109,6 +109,7 @@ 'capped': 'Named expression in a condition', 'lcoe': 'Reported expression', 'marginal_price': 'Dual of a constraint', + 'leak': 'Term a given expression names', }, 'Shifts': { 'ramp': 'Cyclic and acyclic shift', From 74c87d9748b5e03f63572256fbbb727a04d0c434 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:27:21 +0000 Subject: [PATCH 59/75] Name the term in a term collision, and suggest only a name a term can land on Two fragments that declare one term name now hear that terms share one namespace, not the dimension-rows advice. Terms that land on no name are refused with a near miss among the definitions and readings only, since a term name is no place for a term to land; `did_you_mean` takes `listing=False` for the case where only a near miss helps. The how-to says to name each term after its component. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- docs/howto/compose.md | 4 ++++ src/mathspec/composition.py | 27 +++++++++++++++++++++++---- src/mathspec/errors.py | 6 +++--- tests/test_terms.py | 11 ++++++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 42fbc183..480feee7 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -129,6 +129,10 @@ named. Here two files each say what a generator fleet is: fragments 'gas' and 'coal' both declare the parameter 'Generator_p_nom'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the fragment once, and let the data carry both. Different math under one spelling is a rename: call one of them something else. ``` +A term is a named expression like any other, so the terms of two fragments +need two names. Name each term after its component, such as `generation` and +`consumption`, rather than after the sum it adds to. + ## A column read one way and introduced another What a fragment states about a column it reads has to agree with the fragment diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 2ad1f65c..84295e50 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -76,7 +76,6 @@ from __future__ import annotations -import difflib from collections.abc import Mapping from copy import deepcopy from typing import TYPE_CHECKING, cast, get_args, get_origin @@ -237,6 +236,13 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): if key in merged: + author = _author_of(read, section, key) + if section == 'expressions' and (target := _adds(read[author], key) or _adds(sections, key)): + raise LanguageError( + f"fragments '{author}' and '{name}' both declare the expression {key!r}, which a fragment " + f'adds to {target!r} as a term. A term shares one namespace with every named expression: ' + f"name each fragment's term apart, such as after its component." + ) hint = ( " If one fragment adds to the other's definition, write what it adds as `term:` under " '`given: expressions:`.' @@ -244,7 +250,7 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o else '' ) raise LanguageError( - f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " + f"fragments '{author}' and '{name}' both declare the " f'{_singular(section)} {key!r}. Two of the same kind of thing are two rows of a dimension ' f'rather than two fragments: merge the fragment once, and let the data carry both. ' f'Different math under one spelling is a rename: call one of them something else.{hint}' @@ -253,6 +259,12 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o return merged +def _adds(sections: Mapping[str, object], key: str) -> str | None: + """The name one fragment adds *key* to as a term, or ``None`` where it adds no term by that name.""" + given = _mapping(_mapping(sections.get('given')).get('expressions')) + return next((target for target, entry in given.items() if _mapping(entry).get('term') == key), None) + + def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: """Every ``given: expressions:`` entry, the ones two fragments share folded together, without their terms. @@ -341,7 +353,8 @@ def _landed( A term adds to a name another file has: a definition under ``expressions:``, a reading under ``given:`` with no term of its own, or a use in its math. Terms alone would define a name nothing asked for, which - is what a mistyped name looks like, so the refusal names the near miss. + is what a mistyped name looks like, so the refusal names the near miss + among the names a term could land on, which a term is not. """ if key in defined: return @@ -361,9 +374,15 @@ def _landed( *_mapping(_mapping(sections.get('given')).get('expressions')), ) } - {key} + terms = { + _mapping(entry).get('term') + for sections in read.values() + for entry in _mapping(_mapping(sections.get('given')).get('expressions')).values() + } + known -= terms spelled = ', '.join(f"'{name}'" for name in contributors[:-1]) who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" - near = ''.join(f" Did you mean '{name}'?" for name in difflib.get_close_matches(key, sorted(known), n=1)) + near = f' {hint}' if (hint := did_you_mean(key, known, listing=False)) else '' raise LanguageError( f'{who} a term to {key!r}, which no fragment defines, reads or uses. A term adds to a name another ' f"file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling.{near}" diff --git a/src/mathspec/errors.py b/src/mathspec/errors.py index 4251ace2..7a81d1ec 100644 --- a/src/mathspec/errors.py +++ b/src/mathspec/errors.py @@ -59,13 +59,13 @@ class DimensionError(LanguageError): """A dim-set rule was violated. Raised at load time, before any data.""" -def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared') -> str: - """The repair clause for an unrecognised name: the near miss, or the set.""" +def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared', listing: bool = True) -> str: + """The repair clause for an unrecognised name: the near miss, or the set, or nothing where *listing* is off.""" candidates = sorted(known) near = difflib.get_close_matches(name, candidates, n=1, cutoff=0.6) if near: return f"Did you mean '{near[0]}'?" - return f'{label}: {", ".join(candidates) or "nothing"}.' + return f'{label}: {", ".join(candidates) or "nothing"}.' if listing else '' def schema_error(exc: ValidationError) -> LanguageError: diff --git a/tests/test_terms.py b/tests/test_terms.py index 82d4e07d..0728de01 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -244,8 +244,9 @@ def test_a_cased_term_is_added_like_any_other(): [ pytest.param( {'fleet': FLEET, 'demand': DEMAND}, - r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines, reads or uses", - id='terms-and-nothing-else', + r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines, reads or uses\. " + r'.*or fix the spelling\.$', + id='terms-and-nothing-else-with-no-near-miss', ), pytest.param( { @@ -292,7 +293,11 @@ def test_two_terms_of_one_name_collide(): """A term is an ordinary named expression, so two fragments name theirs apart.""" twin = {**FLEET, 'expressions': {'demand_injection': FLEET['expressions']['generator_injection']}} twin = {**twin, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'demand_injection'}}}} - with pytest.raises(LanguageError, match=r"both declare the expression 'demand_injection'"): + with pytest.raises( + LanguageError, + match=r"both declare the expression 'demand_injection', which a fragment adds to 'injection' as a term\. " + r"A term shares one namespace.*name each fragment's term apart", + ): merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': twin}) From 129aa563538ad1166450efa2e6c70c7a691a1b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:42:13 +0000 Subject: [PATCH 60/75] Cut pypsa.yaml into topic fragments whose components add named terms `examples/pypsa/` holds the 24 fragments `tools/pypsa_split.py` writes from #620's `examples/pypsa.yaml`. Each component's share of a sum (the bus balance, the cycle sum, the operating cost, the carrier additions and the five global-constraint totals) is a named expression of its own, such as `Generator_injection`, and the `term:` of its `given:` entry names it. The reader of each sum describes it: `network` and `power_flow` for the two rows, `core` for the rest. `check` merges the fragments to the canonical form of the one file with its sums written as named terms, and compares that file with `pypsa.yaml` row by row, every term substituted back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- examples/pypsa/carrier.yaml | 54 ++ examples/pypsa/core.yaml | 137 +++++ examples/pypsa/cost.yaml | 52 ++ examples/pypsa/generator.yaml | 271 ++++++++++ examples/pypsa/generator_commitment.yaml | 263 ++++++++++ examples/pypsa/generator_maintenance.yaml | 224 +++++++++ examples/pypsa/generator_ramping.yaml | 237 +++++++++ examples/pypsa/global_constraints.yaml | 132 +++++ examples/pypsa/line.yaml | 254 ++++++++++ examples/pypsa/link.yaml | 311 ++++++++++++ examples/pypsa/link_commitment.yaml | 263 ++++++++++ examples/pypsa/link_maintenance.yaml | 224 +++++++++ examples/pypsa/link_ramping.yaml | 237 +++++++++ examples/pypsa/load.yaml | 54 ++ examples/pypsa/network.yaml | 38 ++ examples/pypsa/power_flow.yaml | 32 ++ examples/pypsa/process.yaml | 284 +++++++++++ examples/pypsa/process_commitment.yaml | 263 ++++++++++ examples/pypsa/process_maintenance.yaml | 224 +++++++++ examples/pypsa/process_ramping.yaml | 237 +++++++++ examples/pypsa/security.yaml | 157 ++++++ examples/pypsa/storage_unit.yaml | 461 +++++++++++++++++ examples/pypsa/store.yaml | 357 +++++++++++++ examples/pypsa/transformer.yaml | 255 ++++++++++ tests/test_pypsa_split.py | 128 +++++ tools/pypsa_split.py | 578 ++++++++++++++++++++++ 26 files changed, 5727 insertions(+) create mode 100644 examples/pypsa/carrier.yaml create mode 100644 examples/pypsa/core.yaml create mode 100644 examples/pypsa/cost.yaml create mode 100644 examples/pypsa/generator.yaml create mode 100644 examples/pypsa/generator_commitment.yaml create mode 100644 examples/pypsa/generator_maintenance.yaml create mode 100644 examples/pypsa/generator_ramping.yaml create mode 100644 examples/pypsa/global_constraints.yaml create mode 100644 examples/pypsa/line.yaml create mode 100644 examples/pypsa/link.yaml create mode 100644 examples/pypsa/link_commitment.yaml create mode 100644 examples/pypsa/link_maintenance.yaml create mode 100644 examples/pypsa/link_ramping.yaml create mode 100644 examples/pypsa/load.yaml create mode 100644 examples/pypsa/network.yaml create mode 100644 examples/pypsa/power_flow.yaml create mode 100644 examples/pypsa/process.yaml create mode 100644 examples/pypsa/process_commitment.yaml create mode 100644 examples/pypsa/process_maintenance.yaml create mode 100644 examples/pypsa/process_ramping.yaml create mode 100644 examples/pypsa/security.yaml create mode 100644 examples/pypsa/storage_unit.yaml create mode 100644 examples/pypsa/store.yaml create mode 100644 examples/pypsa/transformer.yaml create mode 100644 tests/test_pypsa_split.py create mode 100644 tools/pypsa_split.py diff --git a/examples/pypsa/carrier.yaml b/examples/pypsa/carrier.yaml new file mode 100644 index 00000000..f762b653 --- /dev/null +++ b/examples/pypsa/carrier.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +parameters: + Carrier_max_growth: + description: >- + most capacity of a carrier that may be added in a period; no value means + no limit. The least over the scenarios, as PyPSA takes it + (`global_constraints.py:226-230`), data prep. PyPSA reads it only under + `multi_investment_periods` (`global_constraints.py:219-220`), so data + prep feeds no value otherwise + dims: [carrier] + Carrier_max_relative_growth: + description: >- + share of the previous period's additions that may be added on top — the + least over the scenarios, as PyPSA takes it, data prep + dims: [carrier] + +given: + expressions: + Carrier_additions: { dims: [period, carrier] } + +expressions: + Carrier_relative_growth: + description: >- + the share of the previous period's additions a carrier's growth limit + reads — PyPSA's `max_relative_growth` clipped at zero, so a negative + share adds nothing and never tightens the limit + dims: [carrier] + cases: + positive: { when: Carrier_max_relative_growth > 0, expression: Carrier_max_relative_growth } + otherwise: 0 + +constraints: + Carrier_growth_limit: + description: >- + `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, + counting each build in the first period it stands in, is at most its allowance plus a share of + what it added the period before; the first period has no predecessor, so `edge=0` leaves it the + bare allowance + dims: [carrier, period] + where: Carrier_max_growth + expression: >- + Carrier_additions + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_relative_growth + <= Carrier_max_growth diff --git a/examples/pypsa/core.yaml b/examples/pypsa/core.yaml new file mode 100644 index 00000000..6155e331 --- /dev/null +++ b/examples/pypsa/core.yaml @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + snapshot_weightings_objective: + description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost + dims: [snapshot] + scenario_weight: + description: PyPSA's `scenario_weightings.weight` — the probability of a future + dims: [scenario] + CVaR_omega: + description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model + dims: [] + period_weight_objective: + description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs + dims: [period] + period_weight_years: + description: >- + PyPSA's `investment_period_weightings.years` — what a period's energy + weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it + only under `multi_investment_periods`, so data prep feeds one otherwise + dims: [period] + snapshot_weightings_stores: + description: PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance + dims: [snapshot] + snapshot_weightings_generators: + description: PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total + dims: [snapshot] + transmission_losses: + description: >- + whether the network dissipates transmission losses — PyPSA's + `transmission_losses` read as a flag; its mode, tangents or secants, + only decides how data prep fills the `segment` axis, the rows are the + same; false with no segments is a lossless run. A security-constrained + run over a network with passive branches builds no loss: PyPSA does not + hand the keyword to `create_model` (`abstract.py:437-441`) but to the + solver (`:491`), so data prep feeds false there + dims: [] + dtype: bool + GlobalConstraint_counts_snapshot: + description: >- + whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every + snapshot where the row names none, and only that period's where it + names one, data prep. A row that names a period the run does not model + has no label here, as PyPSA skips it (`global_constraints.py:377`); + PyPSA reads the column only under `multi_investment_periods`, and fails + on a row that names a period without it (`global_constraints.py:375`) + dims: [scenario, global_constraint, snapshot] + dtype: bool + +given: + expressions: + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + +expressions: + GlobalConstraint_energy_weight: + description: >- + what one unit of power at a snapshot counts for in a row — the + generator weighting times the years of the snapshot's period, where the + row counts the snapshot, and nothing where it does not + dims: [scenario, global_constraint, snapshot] + cases: + counted: + when: GlobalConstraint_counts_snapshot + expression: snapshot_weightings_generators * at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + otherwise: 0 + GlobalConstraint_snapshot_closes: + description: one at the last snapshot a row counts, and zero elsewhere + dims: [scenario, global_constraint, snapshot] + cases: + last_counted: + when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) + expression: 1 + otherwise: 0 diff --git a/examples/pypsa/cost.yaml b/examples/pypsa/cost.yaml new file mode 100644 index 00000000..4c6e433a --- /dev/null +++ b/examples/pypsa/cost.yaml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + +parameters: + CVaR_inv_tail: + description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + dims: [] + +variables: + CVaR_a: + description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" + dims: [scenario] + bounds: + lower: 0 + CVaR_theta: + description: "`CVaR-theta` — where the tail starts, the value at risk" + dims: [] + CVaR: + description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" + dims: [] + +given: + parameters: + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + expressions: + scenario_opex: { dims: [scenario] } + +constraints: + CVaR_excess: + description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" + dims: [scenario] + where: CVaR_omega > 0 + expression: CVaR_a - scenario_opex + CVaR_theta >= 0 + CVaR_def: + description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" + dims: [] + where: CVaR_omega > 0 + expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + +objective: + sense: minimize + description: >- + capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR diff --git a/examples/pypsa/generator.yaml b/examples/pypsa/generator.yaml new file mode 100644 index 00000000..797b53d7 --- /dev/null +++ b/examples/pypsa/generator.yaml @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + generator: + description: generating units, each on one bus + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Generator_carrier: + description: the carrier a generator converts from + key: generator + values: carrier + Generator_bus: + description: the bus a generator sits on + key: generator + values: bus + +parameters: + Generator_p_nom: + description: nominal power + dims: [scenario, generator] + Generator_p_nom_extendable: + description: whether the nominal power is a decision + dims: [generator] + dtype: bool + Generator_p_min_pu: + description: least output, per unit of nominal power + dims: [scenario, snapshot, generator] + Generator_p_max_pu: + description: most output, per unit of nominal power — an availability profile + dims: [scenario, snapshot, generator] + Generator_marginal_cost: + description: cost of one unit of output + dims: [scenario, snapshot, generator] + Generator_marginal_cost_quadratic: + description: cost of the square of one unit of output + dims: [scenario, snapshot, generator] + Generator_sign: + description: >- + the sign output enters its bus's balance with — PyPSA's `sign`, `1` + unless given, `-1` for a unit that draws power. PyPSA refuses one that + differs by scenario (`consistency.py:1187`) + dims: [generator] + Generator_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [generator] + Generator_modules_installed: + description: >- + how many whole modules a committable build has in place: `Generator_p_nom + / Generator_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, generator] + Generator_p_min_pu_nonneg: + description: >- + true where none of the generator's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [generator] + dtype: bool + Generator_active: + description: whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep + dims: [snapshot, generator] + dtype: bool + Generator_capital_weight: + description: the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [generator] + Generator_first_active: + description: >- + one in the first period a generator stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a generator + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, generator] + Generator_p_set: + description: a given output schedule; a generator without one has no row here + dims: [scenario, snapshot, generator] + Generator_p_nom_min: + description: least nominal power an extendable generator may be built at + dims: [scenario, generator] + Generator_p_nom_max: + description: most nominal power an extendable generator may be built at + dims: [scenario, generator] + Generator_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, generator] + Generator_p_nom_set: + description: a given nominal power for an extendable generator; one without a value has no row here + dims: [scenario, generator] + Generator_e_sum_min: + description: least energy over the horizon; minus infinity where no floor is meant + dims: [scenario, generator] + Generator_e_sum_max: + description: most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant + dims: [scenario, generator] + Generator_primary_energy_weight: + description: >- + the constrained attribute per unit of energy at the bus — the carrier's + `co2_emissions` over the generator's efficiency, data prep; a generator + of an unweighted carrier has no row + dims: [scenario, global_constraint, generator] + Generator_operational_limit_weight: + description: one where the generator is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, generator] + Generator_tech_capacity_weight: + description: >- + one where the generator is in the row's carrier-and-bus set — data + prep; one outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [global_constraint, generator] + +variables: + Generator_p: + description: "`Generator-p` — output of a generator in a snapshot" + dims: [scenario, snapshot, generator] + where: Generator_active + Generator_n_mod: + description: "`Generator-n_mod` — how many modules of an extendable modular build" + dims: [generator] + where: Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Generator_p_nom_ext: + description: >- + `Generator-p_nom` — nominal power where it is a decision; the parameter + of the same PyPSA name carries the fixed regime + dims: [generator] + where: Generator_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Generator_committable: { dims: [generator], dtype: bool } + Generator_maintenance_pu: { dims: [scenario, generator] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + snapshot_weightings_generators: { dims: [snapshot] } + variables: + Generator_maintenance: { dims: [scenario, snapshot, generator] } + Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } + expressions: + GlobalConstraint_energy_weight: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: Generator_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: Generator_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: Generator_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Generator_opex } + Carrier_additions: { dims: [period, carrier], term: Generator_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Generator_injection } + +expressions: + Generator_p_nom_effective: + description: the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, generator] + cases: + extendable: { when: Generator_p_nom_extendable, expression: Generator_p_nom_ext } + otherwise: Generator_p_nom + Generator_p_nom_committed: + description: >- + the build a committed unit's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, generator] + cases: + modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } + otherwise: Generator_p_nom + Generator_injection: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + Generator_opex: + expression: >- + sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + Generator_additions: + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + Generator_primary_energy: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + Generator_operational_limit: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + Generator_tech_capacity_expansion: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + +constraints: + Generator_fix_p_lower: + description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) + Generator_fix_p_upper: + description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) + Generator_ext_p_lower: + description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_ext_p_upper: + description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_ext_p_nom_lower: + description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, generator] + where: Generator_p_nom_extendable + expression: Generator_p_nom_ext >= Generator_p_nom_min + Generator_ext_p_nom_upper: + description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, generator] + where: Generator_p_nom_extendable AND Generator_p_nom_max + expression: Generator_p_nom_ext <= Generator_p_nom_max + Generator_p_nom_set: + description: "`Generator-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, generator] + where: Generator_p_nom_extendable AND Generator_p_nom_set + expression: Generator_p_nom_ext == Generator_p_nom_set + Generator_e_sum_min: + description: "`Generator-e_sum_min` — energy over the horizon is at least its floor; a floor of minus infinity is no row" + dims: [scenario, generator] + where: Generator_e_sum_min + expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) >= Generator_e_sum_min + Generator_e_sum_max: + description: "`Generator-e_sum_max` — energy over the horizon is at most its budget; a budget of infinity is no row" + dims: [scenario, generator] + where: Generator_e_sum_max + expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) <= Generator_e_sum_max + Generator_p_nom_modularity: + description: "`Generator-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [generator] + where: Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + expression: Generator_p_nom_ext == Generator_p_nom_mod * Generator_n_mod + Generator_p_set: + description: "`Generator-p_set` — output pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, generator] + where: Generator_p_set AND Generator_active + expression: Generator_p == Generator_p_set + +assumptions: + Generator_marginal_cost_quadratic_without_risk_preference: + holds: "Generator_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Generator_p_nom_ext) * Generator_capital_cost) * Generator_capital_weight) diff --git a/examples/pypsa/generator_commitment.yaml b/examples/pypsa/generator_commitment.yaml new file mode 100644 index 00000000..cbd3c628 --- /dev/null +++ b/examples/pypsa/generator_commitment.yaml @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Generator_committable: + description: whether output is gated by an on/off status decision + dims: [generator] + dtype: bool + Generator_min_up_time: + description: least snapshots a unit stays on once started + dims: [scenario, generator] + dtype: int + Generator_min_down_time: + description: least snapshots a unit stays off once stopped + dims: [scenario, generator] + dtype: int + Generator_status_initial: + description: one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, generator] + dtype: int + Generator_must_stay_up: + description: >- + true while the up time a unit brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, generator] + dtype: bool + Generator_must_stay_down: + description: >- + true while the down time a unit brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, generator] + dtype: bool + Generator_start_up_cost: + description: cost of one start + dims: [scenario, generator] + Generator_shut_down_cost: + description: cost of one stop + dims: [scenario, generator] + Generator_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, generator] + Generator_big_m: + description: a bound safely above any feasible output — the build cap at full availability, data prep + dims: [scenario, generator] + +variables: + Generator_status: + description: >- + `Generator-status` — how much of a committable unit is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + Generator_start_up: + description: "`Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + Generator_shut_down: + description: "`Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Generator_p_nom: { dims: [scenario, generator] } + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_p_min_pu: { dims: [scenario, snapshot, generator] } + Generator_p_max_pu: { dims: [scenario, snapshot, generator] } + Generator_p_nom_mod: { dims: [generator] } + Generator_modules_installed: { dims: [scenario, generator] } + Generator_p_min_pu_nonneg: { dims: [generator], dtype: bool } + Generator_maintenance_pu: { dims: [scenario, generator] } + period_weight_objective: { dims: [period] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + variables: + Generator_p: { dims: [scenario, snapshot, generator] } + Generator_n_mod: { dims: [generator], domain: integer } + Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } + Generator_maintenance_status: { dims: [scenario, snapshot, generator] } + Generator_p_nom_ext: { dims: [generator] } + expressions: + scenario_opex: { dims: [scenario], term: Generator_commitment_opex } + +expressions: + Generator_previous_status: + description: >- + the commitment state a generator carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial } + otherwise: shift(Generator_status, along=snapshot, offset=1) + Generator_commitment_opex: + expression: >- + sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + +constraints: + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_transition_start_up: + description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + expression: Generator_start_up >= Generator_status - Generator_previous_status + Generator_com_transition_shut_down: + description: "`Generator-com-transition-shut-down` — turning off is a stop, counted against the state the unit carried into the snapshot" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + expression: Generator_shut_down >= Generator_previous_status - Generator_status + Generator_com_up_time: + description: >- + `Generator-com-up-time` — a unit started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 AND Generator_active + expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status + Generator_com_down_time: + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active + expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status + Generator_com_status_must_stay_up: + description: "`Generator-com-status-min_up_time_must_stay_up` — a unit still serving the up time it brought in stays on" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_up AND Generator_active + expression: Generator_status == 1 + Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_down AND Generator_active + expression: Generator_status == 0 + Generator_com_ext_p_upper_cap: + description: >- + `Generator-com-ext-p-upper-cap` — a committed extendable unit outputs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_com_ext_p_upper_big_m: + description: "`Generator-com-ext-p-upper-bigM` — off, a unit outputs nothing; on, the big M is no bound" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p <= Generator_big_m * Generator_status + Generator_com_ext_p_lower: + description: >- + `Generator-com-ext-p-lower` — a committed extendable unit outputs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: >- + Generator_p >= + Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + + Generator_big_m * Generator_status - Generator_big_m + Generator_com_ext_p_lower_nonneg: + description: >- + `Generator-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, output is also plainly non-negative, a row the big-M lower + cannot assert while the unit is off + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable + AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p >= 0 + Generator_com_mod_p_lower: + description: >- + `Generator-com-mod-p-lower` — a committed modular unit outputs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_mod_p_upper: + description: >- + `Generator-com-mod-p-upper` — a committed modular unit outputs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_status_p_fixed_upper: + description: >- + `Generator-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_status <= Generator_modules_installed + Generator_start_up_p_fixed_upper: + description: >- + `Generator-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_start_up <= Generator_modules_installed + Generator_shut_down_p_fixed_upper: + description: >- + `Generator-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_shut_down <= Generator_modules_installed + Generator_status_p_nom_variable_upper: + description: "`Generator-status-p_nom-variable-upper` — a modular unit is on only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_status <= Generator_n_mod + Generator_start_up_p_nom_variable_upper: + description: "`Generator-start_up-p_nom-variable-upper` — a modular unit starts only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_start_up <= Generator_n_mod + Generator_shut_down_p_nom_variable_upper: + description: "`Generator-shut_down-p_nom-variable-upper` — a modular unit stops only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_shut_down <= Generator_n_mod diff --git a/examples/pypsa/generator_maintenance.yaml b/examples/pypsa/generator_maintenance.yaml new file mode 100644 index 00000000..1f8ca404 --- /dev/null +++ b/examples/pypsa/generator_maintenance.yaml @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + +relations: + Generator_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a generator that is + not maintainable + key: { scenario: scenario, generator: generator, start: snapshot, covered: snapshot } + +parameters: + Generator_maintainable: + description: >- + whether a generator must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [generator] + dtype: bool + Generator_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, generator] + Generator_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, generator] + dtype: int + Generator_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the generator is not + maintainable. No row reads it: data prep turns it into + `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, generator] + Generator_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the generator does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, generator] + dtype: bool + +variables: + Generator_maintenance: + description: >- + `Generator-maintenance` — whether a maintainable generator is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + bounds: + lower: 0 + upper: 1 + Generator_maintenance_start: + description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + domain: binary + Generator_maintenance_capacity: + description: >- + `Generator-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_p_nom_extendable + AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + absence: zero + bounds: + lower: 0 + Generator_maintenance_status: + description: >- + `Generator-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_committable + AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_committable: { dims: [generator], dtype: bool } + Generator_p_nom_mod: { dims: [generator] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + Generator_p_nom_min: { dims: [scenario, generator] } + Generator_p_nom_max: { dims: [scenario, generator] } + variables: + Generator_status: { dims: [scenario, snapshot, generator], domain: integer } + Generator_p_nom_ext: { dims: [generator] } + +constraints: + Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [scenario, generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events + Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) + Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 + Generator_maintcap_upper: + description: >- + `Generator-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_ext - Generator_p_nom_min * (1 - Generator_maintenance) + Generator_maintcap_upper_nommax: + description: "`Generator-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_max * Generator_maintenance + Generator_maintcap_lower_nommax: + description: "`Generator-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity >= Generator_p_nom_ext - Generator_p_nom_max * (1 - Generator_maintenance) + Generator_maintcap_lower_nommin: + description: "`Generator-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active AND Generator_p_nom_min > 0 + expression: Generator_maintenance_capacity >= Generator_p_nom_min * Generator_maintenance + Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_maintenance + Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 + Generator_maint_modstatus_le_status: + description: "`Generator-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_modstatus_le_maint: + description: >- + `Generator-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_p_nom_max / Generator_p_nom_mod * Generator_maintenance + Generator_maint_modstatus_lb: + description: "`Generator-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status >= Generator_status - Generator_p_nom_max / Generator_p_nom_mod * (1 - Generator_maintenance) + +assumptions: + Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Generator_maintenance_build_cap_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Generator_maintenance_module_count_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) diff --git a/examples/pypsa/generator_ramping.yaml b/examples/pypsa/generator_ramping.yaml new file mode 100644 index 00000000..c128b6b6 --- /dev/null +++ b/examples/pypsa/generator_ramping.yaml @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Generator_ramp_limit_up: + description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] + Generator_ramp_limit_down: + description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] + Generator_ramp_limit_start_up: + description: most output in the snapshot a unit starts, per unit of nominal power + dims: [scenario, generator] + Generator_ramp_limit_shut_down: + description: most output in the snapshot before a unit stops, per unit of nominal power + dims: [scenario, generator] + Generator_p_init: + description: >- + the output a unit brought into the horizon — PyPSA's `p_init`, read + only where the unit came in running; no value means it is unknown, so + the unit carries no ramp row at the first snapshot + dims: [scenario, generator] + +given: + parameters: + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_committable: { dims: [generator], dtype: bool } + Generator_status_initial: { dims: [scenario, generator], dtype: int } + Generator_p_nom_mod: { dims: [generator] } + Generator_big_m: { dims: [scenario, generator] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + variables: + Generator_p: { dims: [scenario, snapshot, generator] } + Generator_status: { dims: [scenario, snapshot, generator], domain: integer } + Generator_start_up: { dims: [scenario, snapshot, generator], domain: integer } + Generator_shut_down: { dims: [scenario, snapshot, generator], domain: integer } + Generator_p_nom_ext: { dims: [generator] } + expressions: + Generator_previous_status: { dims: [scenario, snapshot, generator] } + Generator_p_nom_effective: { dims: [scenario, generator] } + Generator_p_nom_committed: { dims: [scenario, generator] } + +expressions: + Generator_previous_p: + description: >- + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } + otherwise: shift(Generator_p, along=snapshot, offset=1) + Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 + Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 + Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 + Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 + Generator_ramp_up_allowance: + description: >- + how far a generator may raise output between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_up_rate * Generator_p_nom_committed * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom_committed + * (Generator_status - Generator_previous_status) + otherwise: Generator_ramp_up_rate * Generator_p_nom_effective + Generator_ramp_down_allowance: + description: >- + how far a generator may lower output between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_down_rate * Generator_p_nom_committed * Generator_status + + Generator_shut_down_rate * Generator_p_nom_committed + * (Generator_previous_status - Generator_status) + otherwise: Generator_ramp_down_rate * Generator_p_nom_effective + +constraints: + Generator_p_ramp_limit_up_run_big_m: + description: >- + `Generator-p-ramp_limit_up-run-bigM` — a committed extendable unit + raises output no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_p - Generator_previous_p <= + Generator_ramp_up_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_previous_status + Generator_p_ramp_limit_up_start_big_m: + description: >- + `Generator-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable unit ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_p - Generator_previous_p <= + Generator_start_up_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_start_up + Generator_p_ramp_limit_down_run_big_m: + description: >- + `Generator-p-ramp_limit_down-run-bigM` — a committed extendable unit + lowers output no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_previous_p - Generator_p <= + Generator_ramp_down_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_status + Generator_p_ramp_limit_down_shut_big_m: + description: >- + `Generator-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable unit ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_previous_p - Generator_p <= + Generator_shut_down_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_shut_down + Generator_p_ramp_limit_up: + description: >- + `Generator-p-ramp_limit_up` — a generator raises output no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A unit that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead + dims: [scenario, snapshot, generator] + where: >- + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance + Generator_p_ramp_limit_down: + description: >- + `Generator-p-ramp_limit_down` — a generator lowers output no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A unit that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead + dims: [scenario, snapshot, generator] + where: >- + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance + +assumptions: + Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data diff --git a/examples/pypsa/global_constraints.yaml b/examples/pypsa/global_constraints.yaml new file mode 100644 index 00000000..b2df5d5b --- /dev/null +++ b/examples/pypsa/global_constraints.yaml @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + +parameters: + GlobalConstraint_type: + description: >- + which formula the row takes — `primary_energy`, `operational_limit`, + `transmission_volume_expansion_limit`, + `transmission_expansion_cost_limit` or + `tech_capacity_expansion_limit` + dims: [global_constraint] + dtype: str + GlobalConstraint_sense: + description: >- + which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA + reads a row's sense per scenario (`global_constraints.py:556`, `:748`, + `:860`) + dims: [scenario, global_constraint] + dtype: str + GlobalConstraint_constant: + description: >- + the constant the total is held against; what a variable cannot carry — + an initial charge, times its period's years for each counted period + where the storage reopens per period, or a non-extendable build — is + folded in here by data prep. PyPSA reads it per scenario + (`global_constraints.py:557`, `:749`, `:861`) + dims: [scenario, global_constraint] + +given: + parameters: + scenario_weight: { dims: [scenario] } + expressions: + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + +constraints: + GlobalConstraint_primary_energy_ub: + description: "`primary_energy` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '<=' + expression: primary_energy <= GlobalConstraint_constant + GlobalConstraint_primary_energy_lb: + description: "`primary_energy` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '>=' + expression: primary_energy >= GlobalConstraint_constant + GlobalConstraint_primary_energy_eq: + description: "`primary_energy` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '==' + expression: primary_energy == GlobalConstraint_constant + GlobalConstraint_operational_limit_ub: + description: "`operational_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '<=' + expression: operational_limit <= GlobalConstraint_constant + GlobalConstraint_operational_limit_lb: + description: "`operational_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '>=' + expression: operational_limit >= GlobalConstraint_constant + GlobalConstraint_operational_limit_eq: + description: "`operational_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '==' + expression: operational_limit == GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_ub: + description: "`transmission_volume_expansion_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '<=' + expression: transmission_volume_expansion <= GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_lb: + description: "`transmission_volume_expansion_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '>=' + expression: transmission_volume_expansion >= GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_eq: + description: "`transmission_volume_expansion_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '==' + expression: transmission_volume_expansion == GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_ub: + description: "`transmission_expansion_cost_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '<=' + expression: transmission_expansion_cost <= GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_lb: + description: "`transmission_expansion_cost_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '>=' + expression: transmission_expansion_cost >= GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_eq: + description: "`transmission_expansion_cost_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '==' + expression: transmission_expansion_cost == GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_ub: + description: "`tech_capacity_expansion_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '<=' + expression: tech_capacity_expansion <= GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_lb: + description: "`tech_capacity_expansion_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '>=' + expression: tech_capacity_expansion >= GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_eq: + description: "`tech_capacity_expansion_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '==' + expression: tech_capacity_expansion == GlobalConstraint_constant + +assumptions: + GlobalConstraint_tech_capacity_expansion_limit_without_scenarios: + holds: "GlobalConstraint_type != 'tech_capacity_expansion_limit'" + where: "count(scenario_weight, over=scenario) > 1" + description: >- + PyPSA does not build a `tech_capacity_expansion_limit` row on a + network with scenarios and refuses it + (`global_constraints.py:66-68`). The spec cannot tell a network with + one scenario from one with none, so it refuses only where there is + more than one scenario diff --git a/examples/pypsa/line.yaml b/examples/pypsa/line.yaml new file mode 100644 index 00000000..6b744bfd --- /dev/null +++ b/examples/pypsa/line.yaml @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + line: + description: passive branches, each between two buses, their flow set by impedance + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + segment: + description: >- + the cuts a passive branch's loss curve is held above — PyPSA's tangents, + as many as its `segments` count, or its secants, as many as its tolerance + loop places; none in a lossless run + dtype: int + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + Line_carrier: + description: the carrier a line carries + key: line + values: carrier + Line_bus0: + description: the bus a line's flow is measured at + key: line + values: bus + Line_bus1: + description: the bus at a line's other end + key: line + values: bus + +parameters: + Line_active: + description: whether a line stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, line] + dtype: bool + Line_capital_weight: + description: the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [line] + Line_first_active: + description: >- + one in the first period a line stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a line + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, line] + Line_s_nom: + description: nominal apparent power + dims: [scenario, line] + Line_s_nom_extendable: + description: whether the nominal apparent power is a decision + dims: [line] + dtype: bool + Line_s_max_pu: + description: most flow either way, per unit of nominal apparent power + dims: [scenario, snapshot, line] + Line_s_nom_min: + description: least nominal apparent power an extendable line may be built at + dims: [scenario, line] + Line_s_nom_max: + description: most nominal apparent power an extendable line may be built at + dims: [scenario, line] + Line_capital_cost: + description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, line] + Line_s_nom_set: + description: a given nominal apparent power for an extendable line; one without a value has no row here + dims: [scenario, line] + Line_s_set: + description: a given flow schedule; a line without one has no row here + dims: [scenario, snapshot, line] + Line_cycle_weight: + description: >- + the line's series impedance, signed by its orientation in the cycle — + the cycle basis, data prep; a line in no cycle has no row. PyPSA builds + the cycle basis from the first scenario only (`networks.py:1354-1361`) + dims: [line, cycle] + Line_loss_max: + description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep + dims: [scenario, snapshot, line] + Line_loss_slope: + description: >- + the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` + at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between + consecutive breakpoints, data prep + dims: [scenario, snapshot, line, segment] + Line_loss_offset: + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep + dims: [scenario, snapshot, line, segment] + Line_volume_weight: + description: >- + the line's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a + line outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [scenario, global_constraint, line] + Line_expansion_cost_weight: + description: >- + the line's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + line outside the set, or one that does not stand in the row's period, + has no row + dims: [scenario, global_constraint, line] + Line_tech_capacity_weight: + description: >- + one where the line is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, line] + +variables: + Line_s: + description: >- + `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a + positive value withdraws there and injects at `Line_bus1`, lossless + dims: [scenario, snapshot, line] + where: Line_active + Line_loss: + description: >- + `Line-loss` — what a line dissipates carrying its flow, pushed down by the + cost and held up by the cuts; absent, and zero in the balance, where + the network is lossless + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + absence: zero + bounds: + lower: 0 + Line_s_nom_ext: + description: >- + `Line-s_nom` — nominal apparent power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [line] + where: Line_s_nom_extendable + +given: + parameters: + scenario_weight: { dims: [scenario] } + transmission_losses: { dims: [], dtype: bool } + expressions: + transmission_volume_expansion: { dims: [scenario, global_constraint], term: Line_transmission_volume_expansion } + transmission_expansion_cost: { dims: [scenario, global_constraint], term: Line_transmission_expansion_cost } + tech_capacity_expansion: { dims: [global_constraint], term: Line_tech_capacity_expansion } + Carrier_additions: { dims: [period, carrier], term: Line_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Line_injection } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Line_angle_sum } + +expressions: + Line_s_monitored: + description: >- + the flow a line's post-contingency rows read — its flow where it stands, + nothing where it does not, since PyPSA builds those rows for every + branch of the sub-network in every snapshot + dims: [scenario, snapshot, line] + cases: + standing: { when: Line_active, expression: Line_s } + otherwise: 0 + Line_injection: + expression: >- + -sum(Line_s, by=Line_bus0, over=line, into=bus) + + sum(Line_s, by=Line_bus1, over=line, into=bus) + - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) + - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) + Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) + Line_additions: + expression: >- + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + Line_tech_capacity_expansion: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + Line_transmission_volume_expansion: sum(Line_s_nom_ext * Line_volume_weight, over=line) + Line_transmission_expansion_cost: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + +constraints: + Line_fix_s_lower: + description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom + Line_fix_s_upper: + description: "`Line-fix-s-upper` — a fixed line carries at most its rating, the loss included" + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom + Line_ext_s_lower: + description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build, the loss counted against it" + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext + Line_ext_s_upper: + description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build, the loss included" + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext + Line_ext_s_nom_lower: + description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, line] + where: Line_s_nom_extendable + expression: Line_s_nom_ext >= Line_s_nom_min + Line_ext_s_nom_upper: + description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, line] + where: Line_s_nom_extendable AND Line_s_nom_max + expression: Line_s_nom_ext <= Line_s_nom_max + Line_s_nom_set: + description: "`Line-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, line] + where: Line_s_nom_extendable AND Line_s_nom_set + expression: Line_s_nom_ext == Line_s_nom_set + Line_s_set: + description: "`Line-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, line] + where: Line_s_set AND Line_active + expression: Line_s == Line_s_set + Line_loss_upper: + description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + expression: Line_loss <= Line_loss_max + Line_loss_tangents_forward: + description: >- + `Line-loss_tangents-{k}-1`, `Line-loss_secants-pos` — the loss sits above + every cut to its curve for flow one way; PyPSA names one row per tangent + `k`, or one row stacked over its `secant` axis, and this block states them + all over the segment dimension + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset + Line_loss_tangents_reverse: + description: >- + `Line-loss_tangents-{k}--1`, `Line-loss_secants-neg` — the same fan + mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Line_s_nom_ext) * Line_capital_cost) * Line_capital_weight) diff --git a/examples/pypsa/link.yaml b/examples/pypsa/link.yaml new file mode 100644 index 00000000..7c35a95c --- /dev/null +++ b/examples/pypsa/link.yaml @@ -0,0 +1,311 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + link: + description: controllable connections, each from one bus to the buses it delivers to + link_output: + description: >- + a link's output ports, one label per port a link declares — PyPSA's + `bus1`, `bus2`, … columns read long, so a link of any number of output + ports is one term in the balance, data prep + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Link_carrier: + description: the carrier a link converts from + key: link + values: carrier + Link_bus0: + description: the bus a link leaves + key: link + values: bus + Link_output_link: + description: the link an output port belongs to + key: link_output + values: link + Link_output_bus: + description: >- + the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. + A link of three output ports is three labels here rather than a third + relation, so the file states any number of them + key: link_output + values: bus + +parameters: + Link_p_nom: + description: nominal power + dims: [scenario, link] + Link_p_nom_extendable: + description: whether the nominal power is a decision + dims: [link] + dtype: bool + Link_p_min_pu: + description: least flow, per unit of nominal power — negative for a link that carries both ways + dims: [scenario, snapshot, link] + Link_p_max_pu: + description: most flow, per unit of nominal power + dims: [scenario, snapshot, link] + Link_efficiency: + description: >- + share of the flow that arrives at an output port, PyPSA's `efficiency`, + `efficiency2`, … read long — negative where that port consumes rather + than delivers + dims: [scenario, link_output] + Link_output_delay: + description: >- + snapshots a port's delivery lags its link's flow — PyPSA's `delay`, + `delay2`, … read long, in `snapshot_weightings.generators` units, which + the file states as whole snapshots; zero for a port that delivers at once. + Each scenario takes its own. PyPSA `1.3.0` groups the ports by delay over + all scenarios and shifts each group in every one, so a delay that differs + by scenario delivers the flow twice (`constraints.py:1269-1276`, + PyPSA/PyPSA#1941) + dims: [scenario, link_output] + dtype: int + Link_output_cyclic_delay: + description: >- + whether a delayed port's flow wraps from the end of its investment + period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, + the flow still in transit at each period's first snapshots is lost. Each + scenario takes its own, as the delay + dims: [scenario, link_output] + dtype: bool + Link_marginal_cost: + description: cost of one unit of flow + dims: [scenario, snapshot, link] + Link_marginal_cost_quadratic: + description: cost of the square of one unit of flow + dims: [scenario, snapshot, link] + Link_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [link] + Link_modules_installed: + description: >- + how many whole modules a committable build has in place: `Link_p_nom + / Link_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, link] + Link_p_min_pu_nonneg: + description: >- + true where none of the link's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [link] + dtype: bool + Link_active: + description: whether a link stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, link] + dtype: bool + Link_capital_weight: + description: the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [link] + Link_first_active: + description: >- + one in the first period a link stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a link + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, link] + Link_p_set: + description: a given flow schedule; a link without one has no row here + dims: [scenario, snapshot, link] + Link_p_nom_min: + description: least nominal power an extendable link may be built at + dims: [scenario, link] + Link_p_nom_max: + description: most nominal power an extendable link may be built at + dims: [scenario, link] + Link_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, link] + Link_p_nom_set: + description: a given nominal power for an extendable link; one without a value has no row here + dims: [scenario, link] + Link_volume_weight: + description: >- + the link's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a + link outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [scenario, global_constraint, link] + Link_expansion_cost_weight: + description: >- + the link's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + link outside the set, or one that does not stand in the row's period, + has no row + dims: [scenario, global_constraint, link] + Link_tech_capacity_weight: + description: >- + one where the link is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, link] + +variables: + Link_p: + description: >- + `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a + positive value withdraws there and injects at every bus the link's + output ports deliver to + dims: [scenario, snapshot, link] + where: Link_active + Link_n_mod: + description: "`Link-n_mod` — how many modules of an extendable modular build" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Link_p_nom_ext: + description: >- + `Link-p_nom` — nominal power where it is a decision; the parameter of + the same PyPSA name carries the fixed regime + dims: [link] + where: Link_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Link_committable: { dims: [link], dtype: bool } + Link_maintenance_pu: { dims: [scenario, link] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + variables: + Link_maintenance: { dims: [scenario, snapshot, link] } + Link_maintenance_capacity: { dims: [scenario, snapshot, link] } + expressions: + transmission_volume_expansion: { dims: [scenario, global_constraint], term: Link_transmission_volume_expansion } + transmission_expansion_cost: { dims: [scenario, global_constraint], term: Link_transmission_expansion_cost } + tech_capacity_expansion: { dims: [global_constraint], term: Link_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Link_opex } + Carrier_additions: { dims: [period, carrier], term: Link_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Link_injection } + +expressions: + Link_p_nom_effective: + description: the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, link] + cases: + extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } + otherwise: Link_p_nom + Link_p_nom_committed: + description: >- + the build a committed link's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, link] + cases: + modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } + otherwise: Link_p_nom + Link_output_arrival: + description: >- + what a link delivers to an output port at a snapshot — its flow after the + port's efficiency, delayed by the port's `delay` within its investment + period; where the port is `cyclic_delay` the delayed flow wraps from the + period's end, and where it is not the flow still in transit at the + period's first snapshots is lost. A port that does not delay (`delay` + zero) delivers its flow unshifted, cyclic or not + dims: [scenario, snapshot, link_output] + cases: + wrapping: + when: Link_output_cyclic_delay + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) + Link_injection: + expression: >- + -sum(Link_p, by=Link_bus0, over=link, into=bus) + + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + Link_opex: + expression: >- + sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + Link_additions: + expression: >- + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + Link_tech_capacity_expansion: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) + Link_transmission_volume_expansion: sum(Link_p_nom_ext * Link_volume_weight, over=link) + Link_transmission_expansion_cost: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + +constraints: + Link_fix_p_lower: + description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) + Link_fix_p_upper: + description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) + Link_ext_p_lower: + description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p >= Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_ext_p_upper: + description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_ext_p_nom_lower: + description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, link] + where: Link_p_nom_extendable + expression: Link_p_nom_ext >= Link_p_nom_min + Link_ext_p_nom_upper: + description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, link] + where: Link_p_nom_extendable AND Link_p_nom_max + expression: Link_p_nom_ext <= Link_p_nom_max + Link_p_nom_set: + description: "`Link-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, link] + where: Link_p_nom_extendable AND Link_p_nom_set + expression: Link_p_nom_ext == Link_p_nom_set + Link_p_nom_modularity: + description: "`Link-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + expression: Link_p_nom_ext == Link_p_nom_mod * Link_n_mod + Link_p_set: + description: "`Link-p_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, link] + where: Link_p_set AND Link_active + expression: Link_p == Link_p_set + +assumptions: + Link_marginal_cost_quadratic_without_risk_preference: + holds: "Link_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Link_p_nom_ext) * Link_capital_cost) * Link_capital_weight) diff --git a/examples/pypsa/link_commitment.yaml b/examples/pypsa/link_commitment.yaml new file mode 100644 index 00000000..c90e0ec8 --- /dev/null +++ b/examples/pypsa/link_commitment.yaml @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Link_committable: + description: whether flow is gated by an on/off status decision + dims: [link] + dtype: bool + Link_min_up_time: + description: least snapshots a link stays on once started + dims: [scenario, link] + dtype: int + Link_min_down_time: + description: least snapshots a link stays off once stopped + dims: [scenario, link] + dtype: int + Link_status_initial: + description: one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, link] + dtype: int + Link_must_stay_up: + description: >- + true while the up time a link brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, link] + dtype: bool + Link_must_stay_down: + description: >- + true while the down time a link brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, link] + dtype: bool + Link_start_up_cost: + description: cost of one start + dims: [scenario, link] + Link_shut_down_cost: + description: cost of one stop + dims: [scenario, link] + Link_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, link] + Link_big_m: + description: a bound safely above any feasible flow — the build cap at full availability, data prep + dims: [scenario, link] + +variables: + Link_status: + description: >- + `Link-status` — how much of a committable link is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_start_up: + description: "`Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_shut_down: + description: "`Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Link_p_nom: { dims: [scenario, link] } + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_p_min_pu: { dims: [scenario, snapshot, link] } + Link_p_max_pu: { dims: [scenario, snapshot, link] } + Link_p_nom_mod: { dims: [link] } + Link_modules_installed: { dims: [scenario, link] } + Link_p_min_pu_nonneg: { dims: [link], dtype: bool } + Link_maintenance_pu: { dims: [scenario, link] } + period_weight_objective: { dims: [period] } + Link_active: { dims: [snapshot, link], dtype: bool } + variables: + Link_p: { dims: [scenario, snapshot, link] } + Link_n_mod: { dims: [link], domain: integer } + Link_maintenance_capacity: { dims: [scenario, snapshot, link] } + Link_maintenance_status: { dims: [scenario, snapshot, link] } + Link_p_nom_ext: { dims: [link] } + expressions: + scenario_opex: { dims: [scenario], term: Link_commitment_opex } + +expressions: + Link_previous_status: + description: >- + the commitment state a link carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial } + otherwise: shift(Link_status, along=snapshot, offset=1) + Link_commitment_opex: + expression: >- + sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + +constraints: + Link_com_p_lower: + description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_p_upper: + description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_transition_start_up: + description: "`Link-com-transition-start-up` — turning on is a start, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_start_up >= Link_status - Link_previous_status + Link_com_transition_shut_down: + description: "`Link-com-transition-shut-down` — turning off is a stop, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_shut_down >= Link_previous_status - Link_status + Link_com_up_time: + description: >- + `Link-com-up-time` — a link started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_up_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_start_up, along=snapshot, window=Link_min_up_time) <= Link_status + Link_com_down_time: + description: >- + `Link-com-down-time` — a link stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_down_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_shut_down, along=snapshot, window=Link_min_down_time) <= 1 - Link_status + Link_com_status_must_stay_up: + description: "`Link-com-status-min_up_time_must_stay_up` — a link still serving the up time it brought in stays on" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_up AND Link_active + expression: Link_status == 1 + Link_com_status_must_stay_down: + description: >- + `Link-com-status-min_down_time_must_stay_up` — a link still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_down AND Link_active + expression: Link_status == 0 + Link_com_ext_p_upper_cap: + description: >- + `Link-com-ext-p-upper-cap` — a committed extendable link flows + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_com_ext_p_upper_big_m: + description: "`Link-com-ext-p-upper-bigM` — off, a link flows nothing; on, the big M is no bound" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_big_m * Link_status + Link_com_ext_p_lower: + description: >- + `Link-com-ext-p-lower` — a committed extendable link flows at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: >- + Link_p >= + Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + + Link_big_m * Link_status - Link_big_m + Link_com_ext_p_lower_nonneg: + description: >- + `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, flow is also plainly non-negative, a row the big-M lower + cannot assert while the link is off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable + AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p >= 0 + Link_com_mod_p_lower: + description: >- + `Link-com-mod-p-lower` — a committed modular link flows at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_mod_p_upper: + description: >- + `Link-com-mod-p-upper` — a committed modular link flows at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_status_p_fixed_upper: + description: >- + `Link-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_status <= Link_modules_installed + Link_start_up_p_fixed_upper: + description: >- + `Link-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_start_up <= Link_modules_installed + Link_shut_down_p_fixed_upper: + description: >- + `Link-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_shut_down <= Link_modules_installed + Link_status_p_nom_variable_upper: + description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_status <= Link_n_mod + Link_start_up_p_nom_variable_upper: + description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_start_up <= Link_n_mod + Link_shut_down_p_nom_variable_upper: + description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_shut_down <= Link_n_mod diff --git a/examples/pypsa/link_maintenance.yaml b/examples/pypsa/link_maintenance.yaml new file mode 100644 index 00000000..e81eefbf --- /dev/null +++ b/examples/pypsa/link_maintenance.yaml @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + +relations: + Link_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a link that is + not maintainable + key: { scenario: scenario, link: link, start: snapshot, covered: snapshot } + +parameters: + Link_maintainable: + description: >- + whether a link must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [link] + dtype: bool + Link_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, link] + Link_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, link] + dtype: int + Link_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the link is not + maintainable. No row reads it: data prep turns it into + `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, link] + Link_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the link does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, link] + dtype: bool + +variables: + Link_maintenance: + description: >- + `Link-maintenance` — whether a maintainable link is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + bounds: + lower: 0 + upper: 1 + Link_maintenance_start: + description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + domain: binary + Link_maintenance_capacity: + description: >- + `Link-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_p_nom_extendable + AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + absence: zero + bounds: + lower: 0 + Link_maintenance_status: + description: >- + `Link-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_committable + AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_committable: { dims: [link], dtype: bool } + Link_p_nom_mod: { dims: [link] } + Link_active: { dims: [snapshot, link], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + Link_p_nom_min: { dims: [scenario, link] } + Link_p_nom_max: { dims: [scenario, link] } + variables: + Link_status: { dims: [scenario, snapshot, link], domain: integer } + Link_p_nom_ext: { dims: [link] } + +constraints: + Link_maint_event_count: + description: "`Link-maint-event-count` — a maintainable link holds its number of maintenance events over the horizon" + dims: [scenario, link] + where: Link_maintainable + expression: sum(Link_maintenance_start, over=snapshot) == Link_maintenance_events + Link_maint_window: + description: >- + `Link-maint-window` — a link is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + expression: Link_maintenance == sum(Link_maintenance_start, by=Link_maintenance_cover, over=start, into=covered) + Link_maint_start_horizon: + description: "`Link-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active AND Link_maintenance_start_blocked + expression: Link_maintenance_start == 0 + Link_maintcap_upper: + description: >- + `Link-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_ext - Link_p_nom_min * (1 - Link_maintenance) + Link_maintcap_upper_nommax: + description: "`Link-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_max * Link_maintenance + Link_maintcap_lower_nommax: + description: "`Link-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity >= Link_p_nom_ext - Link_p_nom_max * (1 - Link_maintenance) + Link_maintcap_lower_nommin: + description: "`Link-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active AND Link_p_nom_min > 0 + expression: Link_maintenance_capacity >= Link_p_nom_min * Link_maintenance + Link_maint_status_le_status: + description: "`Link-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_status_le_maint: + description: "`Link-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_maintenance + Link_maint_status_lb: + description: "`Link-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status >= Link_status + Link_maintenance - 1 + Link_maint_modstatus_le_status: + description: "`Link-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_modstatus_le_maint: + description: >- + `Link-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_p_nom_max / Link_p_nom_mod * Link_maintenance + Link_maint_modstatus_lb: + description: "`Link-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status >= Link_status - Link_p_nom_max / Link_p_nom_mod * (1 - Link_maintenance) + +assumptions: + Link_maintenance_events_positive: + holds: "Link_maintenance_events > 0" + where: "Link_maintainable" + description: >- + a maintainable link with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Link_maintenance_duration_positive: + holds: "Link_maintenance_duration > 0" + where: "Link_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Link_maintenance_duration_fits_the_horizon: + holds: "Link_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Link_maintenance_events_fit_the_horizon: + holds: "Link_maintenance_duration * Link_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Link_maintenance_build_cap_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Link_maintenance_module_count_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) diff --git a/examples/pypsa/link_ramping.yaml b/examples/pypsa/link_ramping.yaml new file mode 100644 index 00000000..4b643a02 --- /dev/null +++ b/examples/pypsa/link_ramping.yaml @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Link_ramp_limit_up: + description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] + Link_ramp_limit_down: + description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] + Link_ramp_limit_start_up: + description: most flow in the snapshot a link starts, per unit of nominal power + dims: [scenario, link] + Link_ramp_limit_shut_down: + description: most flow in the snapshot before a link stops, per unit of nominal power + dims: [scenario, link] + Link_p_init: + description: >- + the flow a link brought into the horizon — PyPSA's `p_init`, read + only where the link came in running; no value means it is unknown, so + the link carries no ramp row at the first snapshot + dims: [scenario, link] + +given: + parameters: + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_committable: { dims: [link], dtype: bool } + Link_status_initial: { dims: [scenario, link], dtype: int } + Link_p_nom_mod: { dims: [link] } + Link_big_m: { dims: [scenario, link] } + Link_active: { dims: [snapshot, link], dtype: bool } + variables: + Link_p: { dims: [scenario, snapshot, link] } + Link_status: { dims: [scenario, snapshot, link], domain: integer } + Link_start_up: { dims: [scenario, snapshot, link], domain: integer } + Link_shut_down: { dims: [scenario, snapshot, link], domain: integer } + Link_p_nom_ext: { dims: [link] } + expressions: + Link_p_nom_effective: { dims: [scenario, link] } + Link_previous_status: { dims: [scenario, snapshot, link] } + Link_p_nom_committed: { dims: [scenario, link] } + +expressions: + Link_previous_p: + description: >- + the flow a link carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial * Link_p_init } + otherwise: shift(Link_p, along=snapshot, offset=1) + Link_ramp_up_rate: + description: >- + the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, link] + cases: + given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } + otherwise: 1 + Link_ramp_down_rate: + description: >- + the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, link] + cases: + given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } + otherwise: 1 + Link_start_up_rate: + description: >- + the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, link] + cases: + given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } + otherwise: 1 + Link_shut_down_rate: + description: >- + the shut-down ramp a link's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, link] + cases: + given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } + otherwise: 1 + Link_ramp_up_allowance: + description: >- + how far a link may raise flow between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_up_rate * Link_p_nom_committed * Link_previous_status + + Link_start_up_rate * Link_p_nom_committed + * (Link_status - Link_previous_status) + otherwise: Link_ramp_up_rate * Link_p_nom_effective + Link_ramp_down_allowance: + description: >- + how far a link may lower flow between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_down_rate * Link_p_nom_committed * Link_status + + Link_shut_down_rate * Link_p_nom_committed + * (Link_previous_status - Link_status) + otherwise: Link_ramp_down_rate * Link_p_nom_effective + +constraints: + Link_p_ramp_limit_up_run_big_m: + description: >- + `Link-p-ramp_limit_up-run-bigM` — a committed extendable link + raises flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_up_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_previous_status + Link_p_ramp_limit_up_start_big_m: + description: >- + `Link-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable link ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_start_up_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_start_up + Link_p_ramp_limit_down_run_big_m: + description: >- + `Link-p-ramp_limit_down-run-bigM` — a committed extendable link + lowers flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_down_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_status + Link_p_ramp_limit_down_shut_big_m: + description: >- + `Link-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable link ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_shut_down_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_shut_down + Link_p_ramp_limit_up: + description: >- + `Link-p-ramp_limit_up` — a link raises flow no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A link that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead + dims: [scenario, snapshot, link] + where: >- + (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: Link_p - Link_previous_p <= Link_ramp_up_allowance + Link_p_ramp_limit_down: + description: >- + `Link-p-ramp_limit_down` — a link lowers flow no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A link that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead + dims: [scenario, snapshot, link] + where: >- + (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: Link_previous_p - Link_p <= Link_ramp_down_allowance + +assumptions: + Link_came_in_running_unless_committable: + holds: "Link_status_initial == 1" + where: "NOT Link_committable AND (Link_ramp_limit_up OR Link_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a link that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the link there at zero, or at its + start-up ramp where another link of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable link and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data diff --git a/examples/pypsa/load.yaml b/examples/pypsa/load.yaml new file mode 100644 index 00000000..e04ef490 --- /dev/null +++ b/examples/pypsa/load.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + load: + description: demands, each on one bus + +relations: + Load_bus: + description: the bus a load sits on + key: load + values: bus + +parameters: + Load_p_set: + description: demand + dims: [scenario, snapshot, load] + Load_sign: + description: >- + the sign a load's demand enters its bus's balance with — PyPSA's + `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA + refuses one that differs by scenario (`consistency.py:1187`) + dims: [load] + Load_active: + description: >- + whether a load stands in the model — PyPSA's `active`. A load has no + build year and no lifetime, so the flag holds in every snapshot. PyPSA + refuses one that differs by scenario (`consistency.py:1195`) + dims: [load] + dtype: bool + +given: + expressions: + Bus_injection: { dims: [scenario, snapshot, bus], term: Load_injection } + +expressions: + Load_demand: + description: >- + what a load draws from its bus's balance — its demand times its sign + where it is active, nothing where it is not, since PyPSA drops an + inactive load from the balance (`constraints.py:1537-1538`) + dims: [scenario, snapshot, load] + cases: + active: { when: Load_active, expression: Load_sign * Load_p_set } + otherwise: 0 + Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) diff --git a/examples/pypsa/network.yaml b/examples/pypsa/network.yaml new file mode 100644 index 00000000..85d77620 --- /dev/null +++ b/examples/pypsa/network.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + +given: + expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side + +constraints: + Bus_nodal_balance: + description: >- + `Bus-nodal_balance` — what is generated at a bus, storage dispatch and + stores included, less what the links take away, plus what arrives over + them after losses and any delay at every port they deliver to, each + process port drawing or delivering at its own rate and each passive + branch carrying its flow, meets the load there, less half of every + incident line's and transformer's loss — PyPSA dissipates a branch's + loss half at either end. Each generator, storage unit, store and load + term enters with its component's `sign` (`constraints.py:1428-1429`, + `:1538`), and an inactive load not at all. A bus nothing is attached to + has no row; PyPSA refuses one that carries load, and this file does not + yet. + dims: [scenario, snapshot, bus] + expression: Bus_injection == 0 diff --git a/examples/pypsa/power_flow.yaml b/examples/pypsa/power_flow.yaml new file mode 100644 index 00000000..2eedc18a --- /dev/null +++ b/examples/pypsa/power_flow.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + +given: + expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift + +constraints: + Kirchhoff_Voltage_Law: + description: >- + `Kirchhoff-Voltage-Law` — around every independent cycle the + impedance-weighted flows sum to nothing, which is what makes the linear + power flow physical rather than transport. A transformer's flow weighs + its effective reactance, and its phase shift enters the cycle sum too: a + constant where the shift is fixed, or the shift decision times its cycle + weight where the shift is a phase-shifting transformer's to choose + dims: [scenario, snapshot, cycle] + expression: Cycle_angle_sum == 0 diff --git a/examples/pypsa/process.yaml b/examples/pypsa/process.yaml new file mode 100644 index 00000000..3a81196d --- /dev/null +++ b/examples/pypsa/process.yaml @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + process_output: + description: >- + a process's ports, one label per port a process declares — PyPSA's + `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number + of ports is one term in the balance, data prep + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Process_carrier: + description: the carrier a process converts from + key: process + values: carrier + Process_output_process: + description: the process a port belongs to + key: process_output + values: process + Process_output_bus: + description: >- + the bus a port draws from or delivers to — PyPSA's `bus0`, `bus1`, … + columns. A process of three ports is three labels here rather than a + third relation, so the file states any number of them + key: process_output + values: bus + +parameters: + Process_p_nom: + description: nominal internal power + dims: [scenario, process] + Process_p_nom_extendable: + description: whether the nominal internal power is a decision + dims: [process] + dtype: bool + Process_p_min_pu: + description: least internal power, per unit of nominal power — negative for a process that runs both ways + dims: [scenario, snapshot, process] + Process_p_max_pu: + description: most internal power, per unit of nominal power + dims: [scenario, snapshot, process] + Process_rate: + description: >- + the energy a port draws or delivers per unit of internal power, PyPSA's + `rate0`, `rate1`, … read long — negative where the port withdraws, + positive where it injects; a link is a process whose `bus0` rate is minus + one and whose output rates are its efficiencies + dims: [scenario, process_output] + Process_output_delay: + description: >- + snapshots a port's transfer lags its process's internal power — PyPSA's + `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` + units, which the file states as whole snapshots; zero for a port that + transfers at once. Each scenario takes its own, as a link's + dims: [scenario, process_output] + dtype: int + Process_output_cyclic_delay: + description: >- + whether a delayed port's transfer wraps from the end of its investment + period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, + the energy still in transit at each period's first snapshots is lost. + Each scenario takes its own, as the delay + dims: [scenario, process_output] + dtype: bool + Process_marginal_cost: + description: cost of one unit of internal power + dims: [scenario, snapshot, process] + Process_marginal_cost_quadratic: + description: cost of the square of one unit of internal power + dims: [scenario, snapshot, process] + Process_p_set: + description: a given internal power schedule; a process without one has no row here + dims: [scenario, snapshot, process] + Process_p_nom_min: + description: least nominal power an extendable process may be built at + dims: [scenario, process] + Process_p_nom_max: + description: most nominal power an extendable process may be built at + dims: [scenario, process] + Process_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, process] + Process_p_nom_set: + description: a given nominal power for an extendable process; one without a value has no row here + dims: [scenario, process] + Process_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [process] + Process_modules_installed: + description: >- + how many whole modules a committable build has in place: `Process_p_nom + / Process_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, process] + Process_p_min_pu_nonneg: + description: >- + true where none of the process's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [process] + dtype: bool + Process_active: + description: whether a process stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, process] + dtype: bool + Process_capital_weight: + description: the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [process] + Process_first_active: + description: >- + one in the first period a process stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a process + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, process] + Process_tech_capacity_weight: + description: >- + one where the process is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, process] + +variables: + Process_p: + description: >- + `Process-p` — PyPSA's internal power `p`: a positive value drives every + port at its own rate, withdrawing where the rate is negative and injecting + where it is positive + dims: [scenario, snapshot, process] + where: Process_active + Process_n_mod: + description: "`Process-n_mod` — how many modules of an extendable modular build" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Process_p_nom_ext: + description: >- + `Process-p_nom` — nominal internal power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [process] + where: Process_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Process_committable: { dims: [process], dtype: bool } + Process_maintenance_pu: { dims: [scenario, process] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + variables: + Process_maintenance: { dims: [scenario, snapshot, process] } + Process_maintenance_capacity: { dims: [scenario, snapshot, process] } + expressions: + tech_capacity_expansion: { dims: [global_constraint], term: Process_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Process_opex } + Carrier_additions: { dims: [period, carrier], term: Process_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Process_injection } + +expressions: + Process_p_nom_effective: + description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, process] + cases: + extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } + otherwise: Process_p_nom + Process_p_nom_committed: + description: >- + the build a committed process's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, process] + cases: + modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } + otherwise: Process_p_nom + Process_output_arrival: + description: >- + what a process transfers at a port at a snapshot — its internal power + times the port's rate, delayed by the port's `delay` within its + investment period; where the port is `cyclic_delay` the delayed transfer + wraps from the period's end, and where it is not the energy still in + transit at the period's first snapshots is lost. A port that does not + delay (`delay` zero) transfers at once, cyclic or not + dims: [scenario, snapshot, process_output] + cases: + wrapping: + when: Process_output_cyclic_delay + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) + Process_injection: + expression: >- + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + Process_opex: + expression: >- + sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + Process_additions: + expression: >- + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + Process_tech_capacity_expansion: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + +constraints: + Process_fix_p_lower: + description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) + Process_fix_p_upper: + description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) + Process_ext_p_lower: + description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p >= Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_ext_p_upper: + description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_ext_p_nom_lower: + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, process] + where: Process_p_nom_extendable + expression: Process_p_nom_ext >= Process_p_nom_min + Process_ext_p_nom_upper: + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, process] + where: Process_p_nom_extendable AND Process_p_nom_max + expression: Process_p_nom_ext <= Process_p_nom_max + Process_p_nom_set: + description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, process] + where: Process_p_nom_extendable AND Process_p_nom_set + expression: Process_p_nom_ext == Process_p_nom_set + Process_p_nom_modularity: + description: "`Process-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + expression: Process_p_nom_ext == Process_p_nom_mod * Process_n_mod + Process_p_set: + description: "`Process-p_set` — internal power pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, process] + where: Process_p_set AND Process_active + expression: Process_p == Process_p_set + +assumptions: + Process_marginal_cost_quadratic_without_risk_preference: + holds: "Process_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Process_p_nom_ext) * Process_capital_cost) * Process_capital_weight) diff --git a/examples/pypsa/process_commitment.yaml b/examples/pypsa/process_commitment.yaml new file mode 100644 index 00000000..35f34e50 --- /dev/null +++ b/examples/pypsa/process_commitment.yaml @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Process_committable: + description: whether internal power is gated by an on/off status decision + dims: [process] + dtype: bool + Process_min_up_time: + description: least snapshots a process stays on once started + dims: [scenario, process] + dtype: int + Process_min_down_time: + description: least snapshots a process stays off once stopped + dims: [scenario, process] + dtype: int + Process_status_initial: + description: one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, process] + dtype: int + Process_must_stay_up: + description: >- + true while the up time a process brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, process] + dtype: bool + Process_must_stay_down: + description: >- + true while the down time a process brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, process] + dtype: bool + Process_start_up_cost: + description: cost of one start + dims: [scenario, process] + Process_shut_down_cost: + description: cost of one stop + dims: [scenario, process] + Process_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, process] + Process_big_m: + description: a bound safely above any feasible internal power — the build cap at full availability, data prep + dims: [scenario, process] + +variables: + Process_status: + description: >- + `Process-status` — how much of a committable process is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_start_up: + description: "`Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_shut_down: + description: "`Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Process_p_nom: { dims: [scenario, process] } + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_p_min_pu: { dims: [scenario, snapshot, process] } + Process_p_max_pu: { dims: [scenario, snapshot, process] } + Process_p_nom_mod: { dims: [process] } + Process_modules_installed: { dims: [scenario, process] } + Process_p_min_pu_nonneg: { dims: [process], dtype: bool } + Process_maintenance_pu: { dims: [scenario, process] } + period_weight_objective: { dims: [period] } + Process_active: { dims: [snapshot, process], dtype: bool } + variables: + Process_p: { dims: [scenario, snapshot, process] } + Process_n_mod: { dims: [process], domain: integer } + Process_maintenance_capacity: { dims: [scenario, snapshot, process] } + Process_maintenance_status: { dims: [scenario, snapshot, process] } + Process_p_nom_ext: { dims: [process] } + expressions: + scenario_opex: { dims: [scenario], term: Process_commitment_opex } + +expressions: + Process_previous_status: + description: >- + the commitment state a process carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial } + otherwise: shift(Process_status, along=snapshot, offset=1) + Process_commitment_opex: + expression: >- + sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) + +constraints: + Process_com_p_lower: + description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_p_upper: + description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_transition_start_up: + description: "`Process-com-transition-start-up` — turning on is a start, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_start_up >= Process_status - Process_previous_status + Process_com_transition_shut_down: + description: "`Process-com-transition-shut-down` — turning off is a stop, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_shut_down >= Process_previous_status - Process_status + Process_com_up_time: + description: >- + `Process-com-up-time` — a process started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_up_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_start_up, along=snapshot, window=Process_min_up_time) <= Process_status + Process_com_down_time: + description: >- + `Process-com-down-time` — a process stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_down_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_shut_down, along=snapshot, window=Process_min_down_time) <= 1 - Process_status + Process_com_status_must_stay_up: + description: "`Process-com-status-min_up_time_must_stay_up` — a process still serving the up time it brought in stays on" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_up AND Process_active + expression: Process_status == 1 + Process_com_status_must_stay_down: + description: >- + `Process-com-status-min_down_time_must_stay_up` — a process still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_down AND Process_active + expression: Process_status == 0 + Process_com_ext_p_upper_cap: + description: >- + `Process-com-ext-p-upper-cap` — a committed extendable process runs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_com_ext_p_upper_big_m: + description: "`Process-com-ext-p-upper-bigM` — off, a process does not run; on, the big M is no bound" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_big_m * Process_status + Process_com_ext_p_lower: + description: >- + `Process-com-ext-p-lower` — a committed extendable process runs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: >- + Process_p >= + Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + + Process_big_m * Process_status - Process_big_m + Process_com_ext_p_lower_nonneg: + description: >- + `Process-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, internal power is also plainly non-negative, a row the big-M lower + cannot assert while the process is off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable + AND Process_p_min_pu_nonneg AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p >= 0 + Process_com_mod_p_lower: + description: >- + `Process-com-mod-p-lower` — a committed modular process runs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_mod_p_upper: + description: >- + `Process-com-mod-p-upper` — a committed modular process runs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_status_p_fixed_upper: + description: >- + `Process-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_status <= Process_modules_installed + Process_start_up_p_fixed_upper: + description: >- + `Process-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_start_up <= Process_modules_installed + Process_shut_down_p_fixed_upper: + description: >- + `Process-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_shut_down <= Process_modules_installed + Process_status_p_nom_variable_upper: + description: "`Process-status-p_nom-variable-upper` — a modular process is on only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_status <= Process_n_mod + Process_start_up_p_nom_variable_upper: + description: "`Process-start_up-p_nom-variable-upper` — a modular process starts only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_start_up <= Process_n_mod + Process_shut_down_p_nom_variable_upper: + description: "`Process-shut_down-p_nom-variable-upper` — a modular process stops only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_shut_down <= Process_n_mod diff --git a/examples/pypsa/process_maintenance.yaml b/examples/pypsa/process_maintenance.yaml new file mode 100644 index 00000000..2f1b9de8 --- /dev/null +++ b/examples/pypsa/process_maintenance.yaml @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + +relations: + Process_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a process that is + not maintainable + key: { scenario: scenario, process: process, start: snapshot, covered: snapshot } + +parameters: + Process_maintainable: + description: >- + whether a process must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [process] + dtype: bool + Process_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, process] + Process_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, process] + dtype: int + Process_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the process is not + maintainable. No row reads it: data prep turns it into + `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, process] + Process_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the process does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, process] + dtype: bool + +variables: + Process_maintenance: + description: >- + `Process-maintenance` — whether a maintainable process is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + bounds: + lower: 0 + upper: 1 + Process_maintenance_start: + description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + domain: binary + Process_maintenance_capacity: + description: >- + `Process-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_p_nom_extendable + AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + absence: zero + bounds: + lower: 0 + Process_maintenance_status: + description: >- + `Process-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_committable + AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_p_nom_min: { dims: [scenario, process] } + Process_p_nom_max: { dims: [scenario, process] } + Process_committable: { dims: [process], dtype: bool } + Process_p_nom_mod: { dims: [process] } + Process_active: { dims: [snapshot, process], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + variables: + Process_status: { dims: [scenario, snapshot, process], domain: integer } + Process_p_nom_ext: { dims: [process] } + +constraints: + Process_maint_event_count: + description: "`Process-maint-event-count` — a maintainable process holds its number of maintenance events over the horizon" + dims: [scenario, process] + where: Process_maintainable + expression: sum(Process_maintenance_start, over=snapshot) == Process_maintenance_events + Process_maint_window: + description: >- + `Process-maint-window` — a process is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + expression: Process_maintenance == sum(Process_maintenance_start, by=Process_maintenance_cover, over=start, into=covered) + Process_maint_start_horizon: + description: "`Process-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active AND Process_maintenance_start_blocked + expression: Process_maintenance_start == 0 + Process_maintcap_upper: + description: >- + `Process-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_ext - Process_p_nom_min * (1 - Process_maintenance) + Process_maintcap_upper_nommax: + description: "`Process-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_max * Process_maintenance + Process_maintcap_lower_nommax: + description: "`Process-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity >= Process_p_nom_ext - Process_p_nom_max * (1 - Process_maintenance) + Process_maintcap_lower_nommin: + description: "`Process-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active AND Process_p_nom_min > 0 + expression: Process_maintenance_capacity >= Process_p_nom_min * Process_maintenance + Process_maint_status_le_status: + description: "`Process-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_status_le_maint: + description: "`Process-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_maintenance + Process_maint_status_lb: + description: "`Process-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status >= Process_status + Process_maintenance - 1 + Process_maint_modstatus_le_status: + description: "`Process-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_modstatus_le_maint: + description: >- + `Process-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_p_nom_max / Process_p_nom_mod * Process_maintenance + Process_maint_modstatus_lb: + description: "`Process-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status >= Process_status - Process_p_nom_max / Process_p_nom_mod * (1 - Process_maintenance) + +assumptions: + Process_maintenance_events_positive: + holds: "Process_maintenance_events > 0" + where: "Process_maintainable" + description: >- + a maintainable process with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Process_maintenance_duration_positive: + holds: "Process_maintenance_duration > 0" + where: "Process_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Process_maintenance_duration_fits_the_horizon: + holds: "Process_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Process_maintenance_events_fit_the_horizon: + holds: "Process_maintenance_duration * Process_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Process_maintenance_build_cap_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Process_maintenance_module_count_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) diff --git a/examples/pypsa/process_ramping.yaml b/examples/pypsa/process_ramping.yaml new file mode 100644 index 00000000..7bb61fdf --- /dev/null +++ b/examples/pypsa/process_ramping.yaml @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Process_ramp_limit_up: + description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] + Process_ramp_limit_down: + description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] + Process_ramp_limit_start_up: + description: most internal power in the snapshot a process starts, per unit of nominal power + dims: [scenario, process] + Process_ramp_limit_shut_down: + description: most internal power in the snapshot before a process stops, per unit of nominal power + dims: [scenario, process] + Process_p_init: + description: >- + the internal power a process brought into the horizon — PyPSA's `p_init`, read + only where the process came in running; no value means it is unknown, so + the process carries no ramp row at the first snapshot + dims: [scenario, process] + +given: + parameters: + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_committable: { dims: [process], dtype: bool } + Process_status_initial: { dims: [scenario, process], dtype: int } + Process_p_nom_mod: { dims: [process] } + Process_big_m: { dims: [scenario, process] } + Process_active: { dims: [snapshot, process], dtype: bool } + variables: + Process_p: { dims: [scenario, snapshot, process] } + Process_status: { dims: [scenario, snapshot, process], domain: integer } + Process_start_up: { dims: [scenario, snapshot, process], domain: integer } + Process_shut_down: { dims: [scenario, snapshot, process], domain: integer } + Process_p_nom_ext: { dims: [process] } + expressions: + Process_p_nom_effective: { dims: [scenario, process] } + Process_previous_status: { dims: [scenario, snapshot, process] } + Process_p_nom_committed: { dims: [scenario, process] } + +expressions: + Process_previous_p: + description: >- + the internal power a process carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial * Process_p_init } + otherwise: shift(Process_p, along=snapshot, offset=1) + Process_ramp_up_rate: + description: >- + the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, process] + cases: + given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } + otherwise: 1 + Process_ramp_down_rate: + description: >- + the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, process] + cases: + given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } + otherwise: 1 + Process_start_up_rate: + description: >- + the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, process] + cases: + given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } + otherwise: 1 + Process_shut_down_rate: + description: >- + the shut-down ramp a process's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, process] + cases: + given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } + otherwise: 1 + Process_ramp_up_allowance: + description: >- + how far a process may raise internal power between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_up_rate * Process_p_nom_committed * Process_previous_status + + Process_start_up_rate * Process_p_nom_committed + * (Process_status - Process_previous_status) + otherwise: Process_ramp_up_rate * Process_p_nom_effective + Process_ramp_down_allowance: + description: >- + how far a process may lower internal power between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_down_rate * Process_p_nom_committed * Process_status + + Process_shut_down_rate * Process_p_nom_committed + * (Process_previous_status - Process_status) + otherwise: Process_ramp_down_rate * Process_p_nom_effective + +constraints: + Process_p_ramp_limit_up_run_big_m: + description: >- + `Process-p-ramp_limit_up-run-bigM` — a committed extendable process + raises internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_up_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_previous_status + Process_p_ramp_limit_up_start_big_m: + description: >- + `Process-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable process ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_start_up_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_start_up + Process_p_ramp_limit_down_run_big_m: + description: >- + `Process-p-ramp_limit_down-run-bigM` — a committed extendable process + lowers internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_down_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_status + Process_p_ramp_limit_down_shut_big_m: + description: >- + `Process-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable process ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_shut_down_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_shut_down + Process_p_ramp_limit_up: + description: >- + `Process-p-ramp_limit_up` — a process raises internal power no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A process that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead + dims: [scenario, snapshot, process] + where: >- + (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: Process_p - Process_previous_p <= Process_ramp_up_allowance + Process_p_ramp_limit_down: + description: >- + `Process-p-ramp_limit_down` — a process lowers internal power no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A process that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead + dims: [scenario, snapshot, process] + where: >- + (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: Process_previous_p - Process_p <= Process_ramp_down_allowance + +assumptions: + Process_came_in_running_unless_committable: + holds: "Process_status_initial == 1" + where: "NOT Process_committable AND (Process_ramp_limit_up OR Process_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a process that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the process there at zero, or at its + start-up ramp where another process of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable process and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data diff --git a/examples/pypsa/security.yaml b/examples/pypsa/security.yaml new file mode 100644 index 00000000..79f9de02 --- /dev/null +++ b/examples/pypsa/security.yaml @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + line: + description: passive branches, each between two buses, their flow set by impedance + transformer: + description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised + outage: + description: >- + the passive branches a security-constrained run takes out one at a time + — PyPSA's `branch_outages`, each a line or a transformer; none on a + plain run + +relations: + Outage_line: + description: the line an outage takes out; an outage of a transformer has no row + key: outage + values: line + Outage_transformer: + description: the transformer an outage takes out; an outage of a line has no row + key: outage + values: transformer + +parameters: + Line_BODF: + description: >- + the share of an outaged branch's flow a line takes on when that branch + goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row + only where the line and the outage share a sub-network, -1 at the + outaged line itself + dims: [line, outage] + Transformer_BODF: + description: >- + the share of an outaged branch's flow a transformer takes on when that + branch goes out, as a line's; a row only where the transformer and the + outage share a sub-network + dims: [transformer, outage] + +given: + parameters: + Line_s_nom: { dims: [scenario, line] } + Line_s_nom_extendable: { dims: [line], dtype: bool } + Line_s_max_pu: { dims: [scenario, snapshot, line] } + Transformer_s_nom: { dims: [scenario, transformer] } + Transformer_s_nom_extendable: { dims: [transformer], dtype: bool } + Transformer_s_max_pu: { dims: [scenario, snapshot, transformer] } + variables: + Line_s_nom_ext: { dims: [line] } + Transformer_s_nom_ext: { dims: [transformer] } + expressions: + Line_s_monitored: { dims: [scenario, snapshot, line] } + Transformer_s_monitored: { dims: [scenario, snapshot, transformer] } + +expressions: + Outage_s: + description: >- + the flow an outage takes off its branch — the outaged line's or + transformer's flow before it goes out + dims: [scenario, snapshot, outage] + cases: + line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } + otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) + +constraints: + Line_fix_s_lower_security: + description: >- + `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at least the negative + of its rating: its flow takes on its share of the outaged branch's + flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom + Line_fix_s_upper_security: + description: >- + `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at most its rating: its + flow takes on its share of the outaged branch's flow. PyPSA names + one row per outaged component `c` and sub-network `n`; this block + states them all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom + Line_ext_s_lower_security: + description: >- + `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at least the + negative of its rating of the chosen build: its flow takes on its + share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext + Line_ext_s_upper_security: + description: >- + `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at most its rating + of the chosen build: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext + Transformer_fix_s_lower_security: + description: >- + `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at least the + negative of its rating: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper_security: + description: >- + `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at most its + rating: its flow takes on its share of the outaged branch's flow. + PyPSA names one row per outaged component `c` and sub-network `n`; + this block states them all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower_security: + description: >- + `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at least + the negative of its rating of the chosen build: its flow takes on + its share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper_security: + description: >- + `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at most + its rating of the chosen build: its flow takes on its share of the + outaged branch's flow. PyPSA names one row per outaged component + `c` and sub-network `n`; this block states them all over the + outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext diff --git a/examples/pypsa/storage_unit.yaml b/examples/pypsa/storage_unit.yaml new file mode 100644 index 00000000..7b0dbafc --- /dev/null +++ b/examples/pypsa/storage_unit.yaml @@ -0,0 +1,461 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + storage_unit: + description: storage units, dispatch and store behind one bus connection + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + StorageUnit_carrier: + description: the carrier a storage unit converts from + key: storage_unit + values: carrier + StorageUnit_bus: + description: the bus a storage unit sits on + key: storage_unit + values: bus + +parameters: + StorageUnit_active: + description: whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, storage_unit] + dtype: bool + StorageUnit_capital_weight: + description: the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [storage_unit] + StorageUnit_first_active: + description: >- + one in the first period a storage unit stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a storage unit + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, storage_unit] + StorageUnit_p_nom_min: + description: least nominal power an extendable storage unit may be built at + dims: [scenario, storage_unit] + StorageUnit_p_nom_max: + description: most nominal power an extendable storage unit may be built at + dims: [scenario, storage_unit] + StorageUnit_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, storage_unit] + StorageUnit_p_nom_set: + description: a given nominal power for an extendable storage unit; one without a value has no row here + dims: [scenario, storage_unit] + StorageUnit_p_nom: + description: nominal power + dims: [scenario, storage_unit] + StorageUnit_p_nom_extendable: + description: whether the nominal power is a decision + dims: [storage_unit] + dtype: bool + StorageUnit_p_min_pu: + description: most storing, per unit of nominal power and negated + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_max_pu: + description: most dispatch, per unit of nominal power + dims: [scenario, snapshot, storage_unit] + StorageUnit_max_hours: + description: energy capacity, as hours of dispatch at nominal power + dims: [scenario, storage_unit] + StorageUnit_efficiency_store: + description: share of the power drawn from the bus that becomes charge + dims: [scenario, storage_unit] + StorageUnit_efficiency_dispatch: + description: share of the charge drawn down that reaches the bus + dims: [scenario, storage_unit] + StorageUnit_sign: + description: >- + the sign net dispatch enters its bus's balance with — PyPSA's `sign`, + `1` unless given. PyPSA refuses one that differs by scenario + (`consistency.py:1187`) + dims: [storage_unit] + StorageUnit_retention: + description: share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep + dims: [scenario, snapshot, storage_unit] + StorageUnit_inflow: + description: energy arriving per hour, a river into a reservoir + dims: [scenario, snapshot, storage_unit] + StorageUnit_state_of_charge_initial: + description: charge held before the first snapshot + dims: [scenario, storage_unit] + StorageUnit_cyclic_state_of_charge: + description: whether the horizon closes on itself instead of opening on the initial charge + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_cyclic_state_of_charge_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + charge on to the next; it overrides `cyclic_state_of_charge` and + `state_of_charge_initial_per_period`. PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_state_of_charge_initial_per_period: + description: >- + whether each investment period opens on the initial charge instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_opens_late: + description: >- + whether a snapshot is the first a storage unit stands in, where that is + not the first of the horizon — PyPSA's `active.cumsum() == 1` over the + snapshots it stands in, past the first snapshot, data prep; false in a + run where every unit stands throughout + dims: [snapshot, storage_unit] + dtype: bool + StorageUnit_inactive_snapshots: + description: >- + how many snapshots a storage unit does not stand in — PyPSA's + `(~active).sum()`, data prep. A cyclic unit reaches back this many + snapshots further, so it closes on the last snapshot it stands in + dims: [storage_unit] + dtype: int + StorageUnit_marginal_cost: + description: cost of one unit of dispatch + dims: [scenario, snapshot, storage_unit] + StorageUnit_marginal_cost_quadratic: + description: cost of the square of one unit of dispatch; storing is not charged + dims: [scenario, snapshot, storage_unit] + StorageUnit_marginal_cost_storage: + description: cost of one unit of charge held over one snapshot + dims: [scenario, snapshot, storage_unit] + StorageUnit_spill_cost: + description: cost of one unit of inflow passed on unused + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_set: + description: a given net dispatch schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_dispatch_set: + description: a given dispatch schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_store_set: + description: a given charging schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_state_of_charge_set: + description: a given charge schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_primary_energy_weight: + description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row + dims: [scenario, global_constraint, storage_unit] + StorageUnit_operational_limit_weight: + description: one where the storage unit is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, storage_unit] + StorageUnit_tech_capacity_weight: + description: >- + one where the storage unit is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, storage_unit] + +variables: + StorageUnit_p_dispatch: + description: "`StorageUnit-p_dispatch` — power delivered to the bus" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_p_store: + description: "`StorageUnit-p_store` — power drawn from the bus into charge" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_state_of_charge: + description: "`StorageUnit-state_of_charge` — energy held at the end of a snapshot" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_spill: + description: >- + `StorageUnit-spill` — inflow passed on unused. Zero where there is no + inflow, so the balance keeps its row there; the bounds are PyPSA's, on + the variable rather than as rows + dims: [scenario, snapshot, storage_unit] + where: "StorageUnit_inflow > 0 AND StorageUnit_active" + absence: zero + bounds: + lower: 0 + upper: StorageUnit_inflow + StorageUnit_p_nom_ext: + description: >- + `StorageUnit-p_nom` — nominal power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [storage_unit] + where: StorageUnit_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + period_weight_years: { dims: [period] } + snapshot_weightings_stores: { dims: [snapshot] } + GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } + expressions: + GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: StorageUnit_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: StorageUnit_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: StorageUnit_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: StorageUnit_opex } + Carrier_additions: { dims: [period, carrier], term: StorageUnit_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: StorageUnit_injection } + +expressions: + StorageUnit_charge_carried_in: + description: >- + the charge a unit opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial charge, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A unit built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon + dims: [scenario, snapshot, storage_unit] + cases: + cyclic: + when: >- + StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: >- + StorageUnit_retention + * shift(shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=StorageUnit_inactive_snapshots, edge='wrap') + opening: + when: >- + NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: StorageUnit_state_of_charge_initial + period_cyclic: + when: StorageUnit_cyclic_state_of_charge_per_period + expression: >- + StorageUnit_retention + * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: >- + StorageUnit_state_of_charge_initial_per_period AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND position(snapshot, by=snapshot_period, within=period) == 0 + expression: StorageUnit_state_of_charge_initial + otherwise: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1) + StorageUnit_closing_weight: + description: >- + what the charge a unit holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the unit reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [scenario, global_constraint, snapshot, storage_unit] + cases: + per_period: + when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT StorageUnit_state_of_charge_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 + StorageUnit_injection: + expression: >- + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + StorageUnit_opex: + expression: >- + sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + StorageUnit_additions: + expression: >- + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + StorageUnit_primary_energy: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + StorageUnit_operational_limit: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + StorageUnit_tech_capacity_expansion: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + +constraints: + StorageUnit_fix_p_dispatch_lower: + description: "`StorageUnit-fix-p_dispatch-lower` — dispatch is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch >= 0 + StorageUnit_fix_p_dispatch_upper: + description: "`StorageUnit-fix-p_dispatch-upper` — a fixed unit dispatches at most its nominal power" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom + StorageUnit_fix_p_store_lower: + description: "`StorageUnit-fix-p_store-lower` — storing is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store >= 0 + StorageUnit_fix_p_store_upper: + description: >- + `StorageUnit-fix-p_store-upper` — a fixed unit stores at most its + nominal power, the minimum-per-unit column carrying that cap negated + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom + StorageUnit_fix_state_of_charge_lower: + description: "`StorageUnit-fix-state_of_charge-lower` — charge is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge >= 0 + StorageUnit_fix_state_of_charge_upper: + description: "`StorageUnit-fix-state_of_charge-upper` — a fixed unit holds at most its hours at nominal power" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom + StorageUnit_ext_p_dispatch_lower: + description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch >= 0 + StorageUnit_ext_p_dispatch_upper: + description: "`StorageUnit-ext-p_dispatch-upper` — an extendable unit dispatches at most the chosen build" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom_ext + StorageUnit_ext_p_store_lower: + description: "`StorageUnit-ext-p_store-lower` — storing is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store >= 0 + StorageUnit_ext_p_store_upper: + description: >- + `StorageUnit-ext-p_store-upper` — an extendable unit stores at most the + chosen build, the minimum-per-unit column carrying that cap negated + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom_ext + StorageUnit_ext_state_of_charge_lower: + description: "`StorageUnit-ext-state_of_charge-lower` — charge is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge >= 0 + StorageUnit_ext_state_of_charge_upper: + description: "`StorageUnit-ext-state_of_charge-upper` — an extendable unit holds at most its hours at the chosen build" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom_ext + StorageUnit_ext_p_nom_lower: + description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable + expression: StorageUnit_p_nom_ext >= StorageUnit_p_nom_min + StorageUnit_ext_p_nom_upper: + description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_max + expression: StorageUnit_p_nom_ext <= StorageUnit_p_nom_max + StorageUnit_p_nom_set: + description: "`StorageUnit-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_set + expression: StorageUnit_p_nom_ext == StorageUnit_p_nom_set + StorageUnit_energy_balance: + description: >- + `StorageUnit-energy_balance` — the charge carried in, plus what is + stored after its efficiency, less what dispatch draws down before its + own, plus inflow not spilled + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + expression: >- + StorageUnit_state_of_charge == + StorageUnit_charge_carried_in + + StorageUnit_efficiency_store * StorageUnit_p_store * snapshot_weightings_stores + - StorageUnit_p_dispatch * snapshot_weightings_stores / StorageUnit_efficiency_dispatch + + (StorageUnit_inflow - StorageUnit_spill) * snapshot_weightings_stores + StorageUnit_p_set: + description: "`StorageUnit-p_set` — net dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_set AND StorageUnit_active + expression: StorageUnit_p_dispatch - StorageUnit_p_store == StorageUnit_p_set + StorageUnit_p_dispatch_set: + description: "`StorageUnit-p_dispatch_set` — dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_dispatch_set AND StorageUnit_active + expression: StorageUnit_p_dispatch == StorageUnit_p_dispatch_set + StorageUnit_p_store_set: + description: "`StorageUnit-p_store_set` — charging pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_store_set AND StorageUnit_active + expression: StorageUnit_p_store == StorageUnit_p_store_set + StorageUnit_state_of_charge_set: + description: "`StorageUnit-state_of_charge_set` — charge pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_state_of_charge_set AND StorageUnit_active + expression: StorageUnit_state_of_charge == StorageUnit_state_of_charge_set + +assumptions: + StorageUnit_stands_in_one_run: + holds: "count(StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a storage unit stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic storage unit reaches back + `StorageUnit_inactive_snapshots` further to the last of them + StorageUnit_opens_late_only_where_it_opens: + holds: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "StorageUnit_opens_late" + description: >- + `StorageUnit_opens_late` marks the first snapshot the storage unit stands in, past + the first of the horizon, and no other + StorageUnit_opens_late_where_it_opens: + holds: "StorageUnit_opens_late" + where: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a storage unit that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `StorageUnit_opens_late` marks the snapshot + StorageUnit_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "StorageUnit_primary_energy_weight AND StorageUnit_state_of_charge_initial_per_period" + description: >- + PyPSA reads the closing charge of a unit that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:474`) + StorageUnit_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "StorageUnit_primary_energy_weight AND NOT StorageUnit_state_of_charge_initial_per_period" + description: >- + a unit that carries its charge from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:448`) + StorageUnit_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "StorageUnit_operational_limit_weight AND NOT StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:647`) + StorageUnit_marginal_cost_quadratic_without_risk_preference: + holds: "StorageUnit_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * StorageUnit_p_nom_ext) * StorageUnit_capital_cost) * StorageUnit_capital_weight) diff --git a/examples/pypsa/store.yaml b/examples/pypsa/store.yaml new file mode 100644 index 00000000..76c30c98 --- /dev/null +++ b/examples/pypsa/store.yaml @@ -0,0 +1,357 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + store: + description: pure energy stores, each on one bus + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Store_carrier: + description: the carrier a store holds + key: store + values: carrier + Store_bus: + description: the bus a store sits on + key: store + values: bus + +parameters: + Store_active: + description: whether a store stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, store] + dtype: bool + Store_capital_weight: + description: the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [store] + Store_first_active: + description: >- + one in the first period a store stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a store + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, store] + Store_e_nom_min: + description: least nominal capacity an extendable store may be built at + dims: [scenario, store] + Store_e_nom_max: + description: most nominal capacity an extendable store may be built at + dims: [scenario, store] + Store_capital_cost: + description: cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, store] + Store_e_nom_set: + description: a given nominal capacity for an extendable store; one without a value has no row here + dims: [scenario, store] + Store_e_nom: + description: nominal energy capacity + dims: [scenario, store] + Store_e_nom_extendable: + description: whether the nominal energy capacity is a decision + dims: [store] + dtype: bool + Store_e_min_pu: + description: least energy held, per unit of nominal capacity — negative for a store that may go short + dims: [scenario, snapshot, store] + Store_e_max_pu: + description: most energy held, per unit of nominal capacity + dims: [scenario, snapshot, store] + Store_sign: + description: >- + the sign the power a store delivers enters its bus's balance with — + PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by + scenario (`consistency.py:1187`) + dims: [store] + Store_retention: + description: share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep + dims: [scenario, snapshot, store] + Store_e_initial: + description: energy held before the first snapshot + dims: [scenario, store] + Store_e_cyclic: + description: whether the horizon closes on itself instead of opening on the initial energy + dims: [scenario, store] + dtype: bool + Store_e_cyclic_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. + PyPSA reads it only under `multi_investment_periods`, so data prep feeds + false otherwise + dims: [scenario, store] + dtype: bool + Store_e_initial_per_period: + description: >- + whether each investment period opens on the initial energy instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, store] + dtype: bool + Store_opens_late: + description: >- + whether a snapshot is the first a store stands in, where that is not the + first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots + it stands in, past the first snapshot, data prep; false in a run where + every store stands throughout + dims: [snapshot, store] + dtype: bool + Store_inactive_snapshots: + description: >- + how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, + data prep. A cyclic store reaches back this many snapshots further, so it + closes on the last snapshot it stands in + dims: [store] + dtype: int + Store_marginal_cost: + description: cost of one unit of power delivered + dims: [scenario, snapshot, store] + Store_marginal_cost_quadratic: + description: cost of the square of the net power delivered, so charging costs as much as delivering + dims: [scenario, snapshot, store] + Store_marginal_cost_storage: + description: cost of one unit of energy held over one snapshot + dims: [scenario, snapshot, store] + Store_e_set: + description: a given energy schedule; a store without one has no row here + dims: [scenario, snapshot, store] + Store_p_set: + description: a given schedule of power delivered; a store without one has no row here + dims: [scenario, snapshot, store] + Store_primary_energy_weight: + description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row + dims: [scenario, global_constraint, store] + Store_operational_limit_weight: + description: one where the store is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, store] + Store_tech_capacity_weight: + description: >- + one where the store is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, store] + +variables: + Store_e: + description: "`Store-e` — energy held at the end of a snapshot" + dims: [scenario, snapshot, store] + where: Store_active + Store_p: + description: "`Store-p` — power delivered to the bus; charging is negative" + dims: [scenario, snapshot, store] + where: Store_active + Store_e_nom_ext: + description: >- + `Store-e_nom` — nominal capacity where it is a decision; the parameter + of the same PyPSA name carries the fixed regime + dims: [store] + where: Store_e_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + period_weight_years: { dims: [period] } + snapshot_weightings_stores: { dims: [snapshot] } + GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } + expressions: + GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: Store_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: Store_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: Store_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Store_opex } + Carrier_additions: { dims: [period, carrier], term: Store_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Store_injection } + +expressions: + Store_energy_carried_in: + description: >- + the energy a store opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial energy, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A store built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon + dims: [scenario, snapshot, store] + cases: + cyclic: + when: >- + Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: >- + Store_retention + * shift(shift(Store_e, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=Store_inactive_snapshots, edge='wrap') + opening: + when: >- + NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: Store_e_initial + period_cyclic: + when: Store_e_cyclic_per_period + expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: Store_e_initial_per_period AND NOT Store_e_cyclic_per_period AND position(snapshot, by=snapshot_period, within=period) == 0 + expression: Store_e_initial + otherwise: Store_retention * shift(Store_e, along=snapshot, offset=1) + Store_closing_weight: + description: >- + what the energy a store holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the store reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [scenario, global_constraint, snapshot, store] + cases: + per_period: + when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT Store_e_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 + Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + Store_opex: + expression: >- + sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + Store_additions: + expression: >- + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + Store_primary_energy: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) + Store_operational_limit: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) + Store_tech_capacity_expansion: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + +constraints: + Store_fix_e_lower: + description: "`Store-fix-e-lower` — a fixed store holds at least its floor" + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active + expression: Store_e >= Store_e_min_pu * Store_e_nom + Store_fix_e_upper: + description: "`Store-fix-e-upper` — a fixed store holds at most its nominal capacity" + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active + expression: Store_e <= Store_e_max_pu * Store_e_nom + Store_ext_e_lower: + description: "`Store-ext-e-lower` — an extendable store holds at least its floor of the chosen build" + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active + expression: Store_e >= Store_e_min_pu * Store_e_nom_ext + Store_ext_e_upper: + description: "`Store-ext-e-upper` — an extendable store holds at most the chosen build" + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active + expression: Store_e <= Store_e_max_pu * Store_e_nom_ext + Store_ext_e_nom_lower: + description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, store] + where: Store_e_nom_extendable + expression: Store_e_nom_ext >= Store_e_nom_min + Store_ext_e_nom_upper: + description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, store] + where: Store_e_nom_extendable AND Store_e_nom_max + expression: Store_e_nom_ext <= Store_e_nom_max + Store_e_nom_set: + description: "`Store-e_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, store] + where: Store_e_nom_extendable AND Store_e_nom_set + expression: Store_e_nom_ext == Store_e_nom_set + Store_energy_balance: + description: "`Store-energy_balance` — the energy carried in, less what is delivered to the bus" + dims: [scenario, snapshot, store] + where: Store_active + expression: >- + Store_e == + Store_energy_carried_in + - Store_p * snapshot_weightings_stores + Store_e_set: + description: "`Store-e_set` — energy pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_e_set AND Store_active + expression: Store_e == Store_e_set + Store_p_set: + description: "`Store-p_set` — power delivered pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_p_set AND Store_active + expression: Store_p == Store_p_set + +assumptions: + Store_stands_in_one_run: + holds: "count(Store_active AND NOT shift(Store_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a store stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic store reaches back + `Store_inactive_snapshots` further to the last of them + Store_opens_late_only_where_it_opens: + holds: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "Store_opens_late" + description: >- + `Store_opens_late` marks the first snapshot the store stands in, past + the first of the horizon, and no other + Store_opens_late_where_it_opens: + holds: "Store_opens_late" + where: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a store that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `Store_opens_late` marks the snapshot + Store_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "Store_primary_energy_weight AND Store_e_initial_per_period" + description: >- + PyPSA reads the closing energy of a store that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:526`) + Store_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "Store_primary_energy_weight AND NOT Store_e_initial_per_period" + description: >- + a store that carries its energy from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:500`) + Store_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "Store_operational_limit_weight AND NOT Store_e_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:695`) + Store_marginal_cost_quadratic_without_risk_preference: + holds: "Store_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Store_e_nom_ext) * Store_capital_cost) * Store_capital_weight) diff --git a/examples/pypsa/transformer.yaml b/examples/pypsa/transformer.yaml new file mode 100644 index 00000000..2442694b --- /dev/null +++ b/examples/pypsa/transformer.yaml @@ -0,0 +1,255 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + transformer: + description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + segment: + description: >- + the cuts a passive branch's loss curve is held above — PyPSA's tangents, + as many as its `segments` count, or its secants, as many as its tolerance + loop places; none in a lossless run + dtype: int + +relations: + Transformer_bus0: + description: the bus a transformer's flow is measured at + key: transformer + values: bus + Transformer_bus1: + description: the bus at a transformer's other end + key: transformer + values: bus + +parameters: + Transformer_active: + description: whether a transformer stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, transformer] + dtype: bool + Transformer_capital_weight: + description: the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [transformer] + Transformer_s_nom: + description: nominal apparent power + dims: [scenario, transformer] + Transformer_s_nom_extendable: + description: whether the nominal apparent power is a decision + dims: [transformer] + dtype: bool + Transformer_s_max_pu: + description: most flow either way, per unit of nominal apparent power + dims: [scenario, snapshot, transformer] + Transformer_s_nom_min: + description: least nominal apparent power an extendable transformer may be built at + dims: [scenario, transformer] + Transformer_s_nom_max: + description: most nominal apparent power an extendable transformer may be built at + dims: [scenario, transformer] + Transformer_capital_cost: + description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, transformer] + Transformer_s_nom_set: + description: a given nominal apparent power for an extendable transformer; one without a value has no row here + dims: [scenario, transformer] + Transformer_s_set: + description: a given flow schedule; a transformer without one has no row here + dims: [scenario, snapshot, transformer] + Transformer_cycle_weight: + description: >- + the transformer's effective series reactance, `x` times its tap ratio, + signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle + basis, data prep; a transformer in no cycle has no row. From the first + scenario only, as a line's + dims: [transformer, cycle] + Transformer_phase_shift_weight: + description: >- + a fixed transformer's phase shift in radians, signed by its orientation + in the cycle — a constant added to the cycle sum, data prep; zero for a + varying transformer, whose shift is a decision instead, so the constant and + the variable term never both count a shift. A transformer with no shift or + in no cycle has no row + dims: [transformer, cycle] + Transformer_phase_shift_varying: + description: >- + whether a transformer's phase shift is a decision — PyPSA's + `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a + fixed shift carried by `phase_shift`. The shift parameters carry no + scenario: only a cycle row reads them, and PyPSA fails on a transformer in + a cycle on a network with scenarios (`constraints.py:1654`) + dims: [transformer] + dtype: bool + Transformer_phase_shift_min: + description: >- + the least a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is + a decision, otherwise the transformer keeps its fixed `phase_shift` + dims: [transformer] + Transformer_phase_shift_max: + description: >- + the most a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer + dims: [transformer] + Transformer_phase_shift_cycle_weight: + description: >- + the cycle sign for a varying transformer's phase shift, times π/180 so a + shift in degrees enters the cycle sum in radians — data prep; zero for a + fixed transformer or one in no cycle + dims: [transformer, cycle] + Transformer_loss_max: + description: >- + the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * + s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times + the tap ratio, data prep + dims: [scenario, snapshot, transformer] + Transformer_loss_slope: + description: >- + the slope of a cut to a transformer's loss curve — a tangent's + `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, + over the transformer's own `r_pu_eff` and rating, data prep + dims: [scenario, snapshot, transformer, segment] + Transformer_loss_offset: + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep + dims: [scenario, snapshot, transformer, segment] + +variables: + Transformer_s: + description: >- + `Transformer-s` — PyPSA's `p0`, the flow measured at the + `Transformer_bus0` end: a positive value withdraws there and injects at + `Transformer_bus1`, lossless + dims: [scenario, snapshot, transformer] + where: Transformer_active + Transformer_loss: + description: >- + `Transformer-loss` — what a transformer dissipates carrying its flow, as + a line does; absent, and zero in the balance, where the network is + lossless + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + absence: zero + bounds: + lower: 0 + Transformer_phase_shift: + description: >- + `Transformer-phase_shift` — a phase-shifting transformer's voltage angle + shift in degrees, chosen per snapshot to redistribute the flows around its + cycles without moving active power; absent, and zero in the cycle sum, + where the shift is fixed + dims: [scenario, snapshot, transformer] + where: Transformer_phase_shift_varying AND Transformer_active + absence: zero + bounds: + lower: Transformer_phase_shift_min + upper: Transformer_phase_shift_max + Transformer_s_nom_ext: + description: >- + `Transformer-s_nom` — nominal apparent power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [transformer] + where: Transformer_s_nom_extendable + +given: + parameters: + scenario_weight: { dims: [scenario] } + transmission_losses: { dims: [], dtype: bool } + expressions: + Bus_injection: { dims: [scenario, snapshot, bus], term: Transformer_injection } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Transformer_angle_sum } + +expressions: + Transformer_s_monitored: + description: the flow a transformer's post-contingency rows read, as a line's + dims: [scenario, snapshot, transformer] + cases: + standing: { when: Transformer_active, expression: Transformer_s } + otherwise: 0 + Transformer_injection: + expression: >- + -sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) + - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) + Transformer_angle_sum: + expression: >- + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) + +constraints: + Transformer_fix_s_lower: + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating, the loss counted against it" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper: + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating, the loss included" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower: + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build, the loss counted against it" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper: + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build, the loss included" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_nom_lower: + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable + expression: Transformer_s_nom_ext >= Transformer_s_nom_min + Transformer_ext_s_nom_upper: + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_max + expression: Transformer_s_nom_ext <= Transformer_s_nom_max + Transformer_s_nom_set: + description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_set + expression: Transformer_s_nom_ext == Transformer_s_nom_set + Transformer_s_set: + description: "`Transformer-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, transformer] + where: Transformer_s_set AND Transformer_active + expression: Transformer_s == Transformer_s_set + Transformer_loss_upper: + description: "`Transformer-loss_upper` — a transformer dissipates at most the loss at its rating" + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + expression: Transformer_loss <= Transformer_loss_max + Transformer_loss_tangents_forward: + description: >- + `Transformer-loss_tangents-{k}-1`, `Transformer-loss_secants-pos` — the + loss sits above every cut to its curve for flow one way, as a line's + does, over the segment dimension + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss + Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + Transformer_loss_tangents_reverse: + description: >- + `Transformer-loss_tangents-{k}--1`, `Transformer-loss_secants-neg` — the + same fan mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Transformer_s_nom_ext) * Transformer_capital_cost) * Transformer_capital_weight) diff --git a/tests/test_pypsa_split.py b/tests/test_pypsa_split.py new file mode 100644 index 00000000..486ccbe6 --- /dev/null +++ b/tests/test_pypsa_split.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""`examples/pypsa/` is `examples/pypsa.yaml` cut into topic fragments, and `merge` gives the same model back. + +Each fragment reads what another topic declares under `given:`. A sum every +component adds to (the bus balance, the operating cost, the global +constraints) is one each component adds a term to: a named expression of its +own, such as `Generator_injection`, which the `term:` of its `given:` entry +names. One fragment reads each sum with its description. So a component is a +family of files, and leaving the family out leaves a whole model. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mathspec import FORMATS, LanguageError, merge, to_spec, typeset +from mathspec.canonical import canonical_yaml +from tools.pypsa_split import SUM_HOME, Model, fragments, same_rows + +FOLDER = Path(__file__).resolve().parent.parent / 'examples' / 'pypsa' +PATHS = {path.stem: path for path in sorted(FOLDER.glob('*.yaml'))} + + +@pytest.fixture(scope='module') +def model() -> Model: + return Model() + + +@pytest.mark.parametrize('name', sorted(PATHS)) +def test_a_fragment_loads_on_its_own(name): + assert to_spec(PATHS[name]).program + + +def test_the_fragments_merge_to_the_one_file_with_its_hubs_as_sums(model): + merged = merge(PATHS, description=model.data['description']) + assert canonical_yaml(merged) == canonical_yaml(to_spec(model.data)) + assert not merged.program.given, 'every name a fragment reads, another fragment declares' + + +def test_each_sum_is_its_terms_by_name_and_each_term_stays(model): + merged = merge(PATHS) + assert merged.expressions['Bus_injection'].expression == ( + 'Generator_injection + Line_injection + Link_injection + Load_injection + Process_injection' + ' + StorageUnit_injection + Store_injection + Transformer_injection' + ), 'the terms in the order the fragment names sort in' + assert set(model.terms) <= set(merged.expressions), 'every term is a named expression of the composed spec' + + +def test_the_one_file_with_its_hubs_as_sums_states_the_rows_of_pypsa_yaml(model): + assert same_rows(model.original, model.data) == [], 'each hub substituted back gives the rows the file wrote' + + +def test_the_fragments_are_what_the_splitter_writes(model): + written = fragments(model) + assert sorted(written) == sorted(PATHS), 'one file per topic, and no stale one' + assert all(PATHS[name].read_text() == text for name, text in written.items()) + + +#: What a model may leave out, as the fragment names or name prefixes it +#: drops. A component comes as a family; security reads the branches. A +#: reader of a sum no model goes without is not listed: leaving it +#: out leaves its terms nowhere to land, which the test below holds. +OPTIONAL = [ + 'carrier', + 'cost', + 'global_constraints', + 'security', + 'load', + 'storage_unit', + 'store', + 'generator', + 'link', + 'process', + 'generator_ramping', + 'link_ramping', + 'process_ramping', + 'line security', + 'transformer security', + 'line transformer security power_flow', +] + + +def _family(name: str, dropped: list[str]) -> bool: + return any(name == d or (name.startswith(f'{d}_') and d in ('generator', 'link', 'process')) for d in dropped) + + +@pytest.mark.parametrize('dropped', OPTIONAL, ids=[d.replace(' ', '+') for d in OPTIONAL]) +def test_leaving_a_topic_out_leaves_a_whole_model(dropped): + kept = {name: path for name, path in PATHS.items() if not _family(name, dropped.split())} + assert len(kept) < len(PATHS), f'{dropped} names a fragment' + assert not merge(kept).program.given, f'nothing that stays reads what {dropped} declares' + + +def test_every_sum_is_read_with_its_description_in_one_fragment(model): + described = { + name: sorted( + stem for stem, path in PATHS.items() if (g := to_spec(path).given.expressions.get(name)) and g.description + ) + for name in model.sums + } + assert described == {name: [SUM_HOME.get(name, 'core')] for name in model.sums}, ( + 'the reader of a sum no model goes without describes it, and core describes the rest' + ) + + +@pytest.mark.parametrize( + ('dropped', 'sum_name'), + [ + pytest.param('network', 'Bus_injection', id='the-bus-balance'), + pytest.param('power_flow', 'Cycle_angle_sum', id='kirchhoff-with-the-branches-kept'), + ], +) +def test_leaving_out_a_reader_no_model_goes_without_is_refused(dropped, sum_name): + """Only the reader has the sum, so without it the terms land on no name rather than define one.""" + kept = {name: path for name, path in PATHS.items() if name != dropped} + with pytest.raises(LanguageError, match=rf"add a term to '{sum_name}', which no fragment defines, reads or uses"): + merge(kept) + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_every_fragment_and_the_composition_print(fmt): + assert all(typeset(path, fmt) for path in PATHS.values()), f'a fragment rendered nothing in {fmt}' + assert typeset(merge(PATHS), fmt) diff --git a/tools/pypsa_split.py b/tools/pypsa_split.py new file mode 100644 index 00000000..2e079165 --- /dev/null +++ b/tools/pypsa_split.py @@ -0,0 +1,578 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""Cut `examples/pypsa.yaml` into topic fragments, and check that `merge` gives the same model back (experiment, #722). + +Run from the repository root:: + + python -m tools.pypsa_split split OUT # write the topic fragments into OUT + python -m tools.pypsa_split check OUT # load each fragment, merge them, compare + +A fragment reads what another topic declares under `given:`. A row or a named +expression that sums one term per component (a hub) becomes a sum: each +component declares its share as a named expression of its own, such as +`Generator_injection`, and names it as the `term:` of its `given:` entry for +the hub. One fragment reads each hub with its description, so a term always +lands, and a new component is one new fragment. + +`check` compares twice. The merged fragments against the one file with its +hubs written as sums, in the canonical form. And that file against +`examples/pypsa.yaml`, row by row: every hub substituted back where it is read, +and every term of a row moved to one side. +""" + +from __future__ import annotations + +import argparse +import collections +import copy +import difflib +import itertools +import re +import sys +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from mathspec import merge, to_spec +from mathspec._expression_parser import ( + BinaryOperatorNode, + ComparisonNode, + NameNode, + NumberNode, + UnaryOperatorNode, + operand, + parse_expression, + with_children, +) +from mathspec.canonical import canonical_dict, canonical_yaml, normalised +from mathspec.errors import LanguageError + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping + + from mathspec._expression_parser import ArithmeticNode + +SOURCE = Path('examples/pypsa.yaml') +SECTIONS = ('dimensions', 'relations', 'parameters', 'variables', 'expressions', 'constraints', 'assumptions') +FRAME = ('dimensions', 'relations') +IDENT = re.compile(r'[A-Za-z_][A-Za-z0-9_]*') +HEADER = '# SPDX-FileCopyrightText: mathspec Contributors\n#\n# SPDX-License-Identifier: MIT\n' + +#: The topic a declaration goes to, by the first word of its name. +PREFIX_TOPIC = { + 'Generator': 'generator', + 'Link': 'link', + 'Process': 'process', + 'StorageUnit': 'storage_unit', + 'Store': 'store', + 'Line': 'line', + 'Transformer': 'transformer', + 'Load': 'load', + 'Bus': 'network', + 'Kirchhoff': 'power_flow', + 'Cycle': 'power_flow', + 'CVaR': 'cost', + 'Carrier': 'carrier', + 'GlobalConstraint': 'global_constraints', + 'Outage': 'security', +} + +#: The system totals, whose names carry no component, and the data every +#: topic reads: the risk preference, and how a snapshot counts in a global +#: constraint, which a component's share of one reads. +NAME_TOPIC = { + 'CVaR_omega': 'core', + 'GlobalConstraint_counts_snapshot': 'core', + 'GlobalConstraint_energy_weight': 'core', + 'GlobalConstraint_snapshot_closes': 'core', + 'scenario_opex': 'cost', + 'primary_energy': 'global_constraints', + 'operational_limit': 'global_constraints', + 'transmission_volume_expansion': 'global_constraints', + 'transmission_expansion_cost': 'global_constraints', + 'tech_capacity_expansion': 'global_constraints', +} + +#: The rows that sum a term per component: each becomes `name == 0` over an +#: additive expression, which every component adds its terms to. +HUB_ROWS = { + 'Bus_nodal_balance': ( + 'Bus_injection', + 'what every component puts into a bus, less what it takes out of it; PyPSA writes each term into ' + 'the balance, and a load on its right-hand side', + ), + 'Kirchhoff_Voltage_Law': ( + 'Cycle_angle_sum', + 'the voltage angle differences around a cycle: every branch flow times its cycle weight, and every ' + 'transformer phase shift', + ), +} + +#: The named expressions that sum a term per component, each made a sum. +HUB_EXPRESSIONS = ( + 'scenario_opex', + 'Carrier_additions', + 'primary_energy', + 'operational_limit', + 'tech_capacity_expansion', + 'transmission_volume_expansion', + 'transmission_expansion_cost', +) + +#: The fragment that reads a sum with its description: the reader where a +#: model without it is never wanted, so the terms land nowhere without it, and +#: `core` where the reader may go. +SUM_HOME = {'Bus_injection': 'network', 'Cycle_angle_sum': 'power_flow'} + +#: How a term of each sum is named after its hub: `Generator_injection` is +#: what the generators put into `Bus_injection`. +HUB_SHORT = { + 'Bus_injection': 'injection', + 'Cycle_angle_sum': 'angle_sum', + 'scenario_opex': 'opex', + 'Carrier_additions': 'additions', +} + +#: The component each topic that adds a term is named after. +TOPIC_PREFIX = {topic: prefix for prefix, topic in PREFIX_TOPIC.items() if prefix[0].isupper()} | { + 'power_flow': 'Cycle' +} + +Key = tuple[str, str] +Term = tuple[str, 'ArithmeticNode'] + + +#: The components with unit commitment, whose declarations are cut by feature. +COMMITTABLE = ('Generator', 'Link', 'Process') + +#: A feature of a committable component, by a pattern on the rest of the name; +#: the first that matches wins, and a name none matches is the component's own. +FEATURES = ( + ('maintenance', re.compile(r'maint')), + ('ramping', re.compile(r'ramp|_rate$|allowance|previous_p$|p_init|came_in_running')), + ('commitment', re.compile(r'status|start_up|shut_down|stand_by|committable|com_|must_stay|min_up|min_down|big_m')), +) + + +def term_name(hub: str, name: str) -> str: + """What the topic *name* calls its term of *hub*: `Generator_commitment_opex` for the start-up costs.""" + base = next(b for b in sorted(TOPIC_PREFIX, key=len, reverse=True) if name == b or name.startswith(f'{b}_')) + feature = name.removeprefix(base).removeprefix('_') + return '_'.join(filter(None, (TOPIC_PREFIX[base], feature, HUB_SHORT.get(hub, hub)))) + + +def topic(name: str) -> str: + """The fragment a declaration of *name* goes to.""" + if 'security' in name or 'BODF' in name: + return 'security' + prefix, _, rest = name.partition('_') + base = NAME_TOPIC.get(name) or PREFIX_TOPIC.get(prefix, 'core') + if prefix in COMMITTABLE: + return next((f'{base}_{feature}' for feature, pattern in FEATURES if pattern.search(rest)), base) + return base + + +# --------------------------------------------------------------------------- +# the source file +# --------------------------------------------------------------------------- + + +def _sliced(text: str) -> dict[Key, str]: + """The source lines of every declaration, cut where the next one at the same indent starts. + + Slicing text rather than dumping parsed data keeps each block's formatting, + so a fragment reads like the file it was cut from. + """ + lines = text.splitlines() + starts: list[tuple[int, str | None, str | None]] = [] + section = None + for i, line in enumerate(lines): + if top := re.match(r'^([a-z_]+):', line): + section = top.group(1) + starts.append((i, section, None)) + elif (entry := re.match(r'^ ([A-Za-z_]\w*):', line)) and section in SECTIONS: + starts.append((i, section, entry.group(1))) + starts.append((len(lines), None, None)) + blocks = {} + for (i, section, name), (j, _, _) in itertools.pairwise(starts): + if section and name: + blocks[section, name] = '\n'.join(lines[i:j]).rstrip() + return blocks + + +def _names_in(value: object, known: Mapping[str, str]) -> set[str]: + """Every name in *known* that *value* reads, prose left out.""" + if isinstance(value, str): + return {token for token in IDENT.findall(value) if token in known} + if isinstance(value, dict): + return set().union(*(_names_in(v, known) for k, v in value.items() if k != 'description')) + if isinstance(value, list): + return set().union(*(_names_in(v, known) for v in value)) + return set() + + +def _signed_terms(node: ArithmeticNode, sign: str = '+') -> Iterator[Term]: + """*node* as a flat sum, each term with the sign it is written under, a minus carried into a bracket.""" + flip = {'+': '-', '-': '+'} + if isinstance(node, BinaryOperatorNode) and node.op in ('+', '-'): + yield from _signed_terms(node.left, sign) + yield from _signed_terms(node.right, sign if node.op == '+' else flip[sign]) + elif isinstance(node, UnaryOperatorNode) and node.op == '-': + yield from _signed_terms(node.operand, flip[sign]) + elif not (isinstance(node, NumberNode) and node.value == 0): + yield sign, node + + +def _written(terms: list[Term]) -> str: + """Signed terms as one expression, one term per line of a folded block.""" + (sign, head), *rest = terms + first = str(head) if sign == '+' else f'-{operand(head)}' + return '\n'.join([first, *(f'{s} {operand(node)}' for s, node in rest)]) + + +def _entry(block: object) -> dict[str, Any]: + """A named expression as a mapping, however the file wrote it.""" + return dict(block) if isinstance(block, dict) else {'expression': block} + + +class Model: + """`examples/pypsa.yaml` with every hub written as the sum of its named terms, one per topic. + + `data` is what `merge` gives back from the fragments: each hub defined as + its terms by name in the order the topics sort in, with the hub's + description, and each term a named expression. + """ + + def __init__(self, path: Path = SOURCE) -> None: + text = path.read_text() + self.original: dict[str, Any] = yaml.safe_load(text) + self.data: dict[str, Any] = copy.deepcopy(self.original) + self.blocks = _sliced(text) + self.generated: set[Key] = set() + self.kind = {n: s for s in SECTIONS if s not in ('constraints', 'assumptions') for n in self.data.get(s, {})} + bodies: dict[str, list[Term]] = {} + said = dict(HUB_ROWS.values()) + for row, (name, _) in HUB_ROWS.items(): + block = self.data['constraints'][row] + compared = parse_expression(block['expression']) + assert isinstance(compared, ComparisonNode), f'{row} is a comparison' + assert compared.op == '==', f'{row} is a balance' + bodies[name] = [*_signed_terms(compared.left), *_signed_terms(compared.right, '-')] + self.data['constraints'][row] = {**block, 'expression': f'{name} == 0'} + self.generated.add(('constraints', row)) + for name in HUB_EXPRESSIONS: + entry = _entry(self.data['expressions'][name]) + said[name] = entry.get('description') + bodies[name] = list(_signed_terms(parse_expression(entry['expression']))) + self.shares: dict[str, dict[str, list[Term]]] = {} + self.terms: dict[str, str] = {} + for name, terms in bodies.items(): + by_topic: dict[str, list[Term]] = collections.defaultdict(list) + for sign, node in terms: + by_topic[self._owner(node)].append((sign, node)) + self.shares[name] = dict(sorted(by_topic.items())) + for owner, share in self.shares[name].items(): + term = term_name(name, owner) + assert term not in self.kind and term not in self.terms, f'{term} names one thing' + self.terms[term] = name + self.data['expressions'][term] = {'expression': _written(share)} + self.generated.add(('expressions', term)) + hub = {'expression': ' + '.join(term_name(name, owner) for owner in self.shares[name])} + self.data['expressions'][name] = {**hub, 'description': said[name]} if said[name] else hub + self.generated.add(('expressions', name)) + self.kind |= dict.fromkeys((*self.terms, *bodies), 'expressions') + frames = self.frames() + self.sums = {name: {'dims': list(frames[name]), 'description': said[name]} for name in bodies} + + def _owner(self, node: ArithmeticNode) -> str: + """The topic of a hub's term: the component whose variable it reads, else whose expression or data.""" + read = [n for n in IDENT.findall(str(node)) if n in self.kind and self.kind[n] not in FRAME] + return topic(min(read, key=lambda n: ('variables', 'expressions', 'parameters').index(self.kind[n]))) + + def names_in(self, value: object) -> set[str]: + return _names_in(value, self.kind) + + def key(self, name: str) -> Key: + return self.kind[name], name + + @property + def keys(self) -> list[Key]: + """Every declaration, in the order of the source file, the new hubs after the source's own.""" + return [*self.blocks, *sorted(k for k in self.generated if k not in self.blocks)] + + def frames(self) -> dict[str, tuple[str, ...]]: + """The frame of every named expression, read off the one file with its hubs rewritten.""" + return {name: e.dims for name, e in to_spec(self.data).program.expressions.items()} + + def home(self, name: str) -> str: + """The fragment that reads the sum *name* with its description.""" + return SUM_HOME.get(name, 'core') + + +# --------------------------------------------------------------------------- +# the fragments +# --------------------------------------------------------------------------- + + +def fragments(model: Model) -> dict[str, str]: + """Each topic's fragment as YAML text: what it owns, its shares of the hubs, and what it reads under `given:`.""" + frames = model.frames() + owned: dict[str, set[Key]] = collections.defaultdict(set) + for key in model.keys: + if key[0] not in FRAME and not (key[0] == 'expressions' and (key[1] in model.shares or key[1] in model.terms)): + owned[topic(key[1])].add(key) + sharing = {t for by_topic in model.shares.values() for t in by_topic} + terms = _objective_terms(model) + homes = {model.home(hub) for hub in model.sums} + + written = {} + for name in sorted({*owned, *sharing, *terms, *homes}): + mine = owned[name] + my_shares = {hub: by_topic[name] for hub, by_topic in model.shares.items() if name in by_topic} + homes_here = {hub for hub in model.sums if model.home(hub) == name} + read = set().union( + *(model.names_in(model.data[s][n]) for s, n in mine), + *(model.names_in(_written(t)) for t in my_shares.values()), + *map(model.names_in, terms[name]), + homes_here, + my_shares, + ) + defined = mine | {('expressions', term_name(hub, name)) for hub in my_shares} + given = {model.key(n) for n in read if model.key(n)[0] not in FRAME and model.key(n) not in defined} + stated = { + **{n: model.data[s][n]['dims'] for s, n in given if s in ('parameters', 'variables')}, + **{n: list(frames[n]) for s, n in given if s == 'expressions'}, + } + frame_reads = read | set().union(*(model.names_in(dims) for dims in stated.values())) + frame = {model.key(n) for n in frame_reads if model.key(n)[0] in FRAME} + frame |= {model.key(n) for key in list(frame) for n in model.names_in(model.data[key[0]][key[1]])} + adds = {hub: term_name(hub, name) for hub in my_shares} + written[name] = _fragment(model, mine | frame, my_shares, given, stated, terms[name], adds, homes_here) + return written + + +def _objective_terms(model: Model) -> dict[str, list[str]]: + """The objective's terms by topic, the operating cost and its tail with the risk rows.""" + terms: dict[str, list[str]] = collections.defaultdict(list) + for sign, node in _signed_terms(parse_expression(model.data['objective']['expression'])): + names = model.names_in(str(node)) + if 'CVaR_omega' in names: + owner = 'cost' + else: + owner = topic(next(n for n in sorted(names) if model.kind[n] == 'variables')) + terms[owner].append(str(node) if sign == '+' else f'-{operand(node)}') + return terms + + +def _block(model: Model, key: Key) -> str: + """One declaration as the source wrote it, or as the hub rewrite wrote it.""" + if key not in model.generated: + return model.blocks[key] + section, name = key + return _dumped(name, _entry(model.data[section][name])) + + +def _dumped(name: str, block: Mapping[str, object]) -> str: + """A generated declaration, its long text folded one term per line.""" + lines = [f' {name}:'] + for field, value in block.items(): + if value is None: + continue + if isinstance(value, str) and field in ('expression', 'description') and ('\n' in value or len(value) > 80): + body = ( + value.split('\n') + if field == 'expression' + else textwrap.wrap(value, 72, break_on_hyphens=False, break_long_words=False) + ) + lines += [f' {field}: >-', *(f' {line}' for line in body)] + else: + dumped = yaml.safe_dump(value, default_flow_style=True, width=1000).removesuffix('\n...\n') + lines.append(f' {field}: {dumped.strip()}') + return '\n'.join(lines) + + +def _fragment( + model: Model, + included: set[Key], + shares: Mapping[str, list[Term]], + given: set[Key], + stated: Mapping[str, list[str]], + terms: list[str], + adds: Mapping[str, str], + homes: set[str], +) -> str: + """One fragment as YAML text, its sections and declarations in the order of the source file.""" + parts = [HEADER] + for section in SECTIONS: + blocks = [_block(model, key) for key in model.keys if key[0] == section and key in included] + if section == 'expressions': + blocks += [_share(adds[hub], share) for hub, share in shares.items()] + if blocks: + parts.append(f'{section}:\n' + '\n'.join(blocks) + '\n') + if section == 'variables' and given: + parts.append('given:\n' + ''.join(_given(model, kind, given, stated, adds, homes) for kind in GIVEN_KINDS)) + if terms: + objective = model.data['objective'] + said = f' description: >-\n {objective["description"]}\n' if 'CVaR_omega' in ''.join(terms) else '' + joined = '\n + '.join(terms) + parts.append(f'objective:\n sense: minimize\n{said} expression: >-\n {joined}\n') + return '\n'.join(parts) + + +def _share(term: str, terms: list[Term]) -> str: + """One topic's term of a hub: an ordinary named expression under the term's own name.""" + written = _written(terms) + if '\n' in written or len(written) > 80: + return _dumped(term, {'expression': written}) + return f' {term}: {written}' + + +#: The kinds a fragment reads under `given:`, and the fields of the source +#: declaration each restates beside the frame. +GIVEN_KINDS = {'parameters': ('dtype',), 'variables': ('domain',), 'expressions': ()} + + +def _given( + model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], adds: Mapping[str, str], homes: set[str] +) -> str: + """One kind of a fragment's `given:` block, an entry per line in source order: a term it adds, a sum's prose.""" + names = [n for s, n in model.keys if s == kind and (s, n) in given] + if not names: + return '' + lines = [f' {kind}:'] + for n in names: + if kind == 'expressions' and n in homes: + entry = { + 'dims': stated[n], + **({'term': adds[n]} if n in adds else {}), + 'description': model.sums[n]['description'], + } + lines.append(_dumped(n, entry).replace('\n', '\n ').replace(f' {n}:', f' {n}:', 1)) + continue + extra = {f: _entry(model.data[kind][n])[f] for f in GIVEN_KINDS[kind] if f in _entry(model.data[kind][n])} + if kind == 'expressions' and n in adds: + extra['term'] = adds[n] + fields = {'dims': stated[n], **extra} + spelled = ', '.join(f'{k}: [{", ".join(v)}]' if isinstance(v, list) else f'{k}: {v}' for k, v in fields.items()) + lines.append(f' {n}: {{ {spelled} }}') + return '\n'.join(lines) + '\n' + + +# --------------------------------------------------------------------------- +# the checks +# --------------------------------------------------------------------------- + + +def _inlined(node: ArithmeticNode, bodies: Mapping[str, ArithmeticNode]) -> ArithmeticNode: + """*node* with every name in *bodies* replaced by its body, and the names in that body in turn.""" + if isinstance(node, NameNode) and node.name in bodies: + return _inlined(bodies[node.name], bodies) + return with_children(node, lambda child: _inlined(child, bodies)) + + +def _row(text: str, bodies: Mapping[str, ArithmeticNode]) -> tuple[str, collections.Counter[tuple[str, str]]]: + """A row or an expression as its operator and the multiset of its terms, every term on the left.""" + node = parse_expression(text) + if isinstance(node, ComparisonNode): + op = node.op + terms = [*_signed_terms(_inlined(node.left, bodies)), *_signed_terms(_inlined(node.right, bodies), '-')] + else: + op, terms = '', list(_signed_terms(_inlined(node, bodies))) + return op, collections.Counter((sign, str(normalised(term))) for sign, term in terms) + + +def same_rows(original: Mapping[str, Any], rewritten: Mapping[str, Any]) -> list[str]: + """Every way *rewritten* states other rows than *original*, once the hubs *original* lacks are substituted. + + The declarations other than rows and named expressions are compared in the + canonical form. A named expression is compared term by term, and one + *original* does not declare has to be a hub or a term of one. + """ + before, after = (canonical_dict(to_spec(dict(model))) for model in (original, rewritten)) + faults = [ + f'{section} differ' + for section in ('dimensions', 'relations', 'parameters', 'variables', 'assumptions', 'macros') + if before.get(section) != after.get(section) + ] + added = set(after['expressions']) - set(before['expressions']) + faults += [f'{name} is new and is neither a hub nor a term' for name in added - {*HUB_NAMES, *_term_names()}] + bodies = {name: parse_expression(_entry(after['expressions'][name])['expression']) for name in added} + + def compared(label: str, one: Mapping[str, Any], two: Mapping[str, Any]) -> None: + beside = [{k: v for k, v in side.items() if k != 'expression'} for side in (one, two)] + if beside[0] != beside[1]: + faults.append(f'{label} differs beside its expression') + if _row(one['expression'], {}) != _row(two['expression'], bodies): + faults.append(f'{label} states another row') + + if set(before['constraints']) != set(after['constraints']): + faults.append('the constraints are not the same names') + for name in before['constraints'].keys() & after['constraints'].keys(): + compared(f'constraint {name}', before['constraints'][name], after['constraints'][name]) + for name, entry in before['expressions'].items(): + one, two = _entry(entry), _entry(after['expressions'][name]) + if 'expression' in one: + compared(f'expression {name}', one, two) + elif one != two: + faults.append(f'expression {name} differs') + compared('the objective', before['objective'], after['objective']) + return faults + + +#: Every hub, the rows' and the named expressions'. +HUB_NAMES = (*(name for name, _ in HUB_ROWS.values()), *HUB_EXPRESSIONS) + + +def _term_names() -> set[str]: + """Every name a term may take: each component, with each feature, for each hub.""" + topics = {*TOPIC_PREFIX, *(f'{t}_{f}' for t in (p.lower() for p in COMMITTABLE) for f, _ in FEATURES)} + return {term_name(hub, t) for hub in HUB_NAMES for t in topics} + + +def check(folder: Path) -> int: + """Load each fragment in *folder*, merge them, and compare with the rewritten one file and with `examples/pypsa.yaml`.""" + model = Model() + paths = {path.stem: path for path in sorted(folder.glob('*.yaml'))} + failed = 0 + for name, path in paths.items(): + try: + to_spec(path) + except LanguageError as e: + failed += 1 + print(f'{name} does not load alone: {e}') + print(f'{len(paths) - failed}/{len(paths)} fragments load alone') + rewritten = to_spec(model.data) + try: + merged = merge(paths, description=rewritten.description) + except LanguageError as e: + print(f'merge refuses the fragments: {e}') + return 1 + one, composed = canonical_yaml(rewritten).splitlines(), canonical_yaml(merged).splitlines() + diff = list(difflib.unified_diff(one, composed, 'one file', 'merged', lineterm='')) + print('\n'.join(diff) if diff else 'the merged fragments and the one file have one canonical form') + faults = same_rows(model.original, model.data) + print('\n'.join(faults) if faults else f'the one file with its hubs as sums states the rows of {SOURCE}') + return 1 if diff or failed or faults else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog='python -m tools.pypsa_split', description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest='command', required=True) + commands.add_parser('split').add_argument('out', type=Path) + commands.add_parser('check').add_argument('folder', type=Path) + args = parser.parse_args(argv) + if args.command == 'split': + args.out.mkdir(parents=True, exist_ok=True) + for name, text in fragments(Model()).items(): + (args.out / f'{name}.yaml').write_text(text) + print(f'{name:20} {len(text.splitlines()):5} lines') + return 0 + return check(args.folder) + + +if __name__ == '__main__': + sys.exit(main()) From c351d413f4319d0b6f63dd8553ab0fe510dbd66d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:42:51 +0000 Subject: [PATCH 61/75] Add the changelog line for #736 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61d7c7b..80965ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version - docs(pypsa): a single spec covers every pypsa model class and component ([#620](https://github.com/energy-models/mathspec/pull/620)) +- docs(pypsa): the pypsa spec is also 24 topic files that merge back to it, each component adding its share of a sum by name ([#736](https://github.com/energy-models/mathspec/pull/736)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) From fcc2fa194e957b015e5cbbbdf3b0429d47a61ac4 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:26:04 +0200 Subject: [PATCH 62/75] fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out (#739) * Choose a merged description by fragment name, and let a reader's fill a gap A shared dimension or relation, a reading several fragments share, and a sum built from terms took the description of whichever fragment was passed first. Each now takes the first description in the fragments' name order, as the objective already did, so the order of the arguments reaches no field. The order of the declarations still follows the order passed in, which is presentation. A folded given declaration dropped its reader's description even where the declaration it folds into had none; it now fills that gap, and yields to the owner's own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 * Add the changelog line for #739 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + docs/howto/compose.md | 6 ++-- src/mathspec/composition.py | 38 +++++++++++++++++++++---- tests/test_composition.py | 56 ++++++++++++++++++++++++++++++++----- tests/test_terms.py | 11 ++++++++ 5 files changed, 96 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d54c853a..1bf54364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) +- fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out ([#739](https://github.com/energy-models/mathspec/pull/739)) ## 0.2.0 (2026-09-25) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 480feee7..fc7b8fe2 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -110,13 +110,13 @@ compose as `override(merge({…}), {…})`. | The entry | What happens | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in | | any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | | a given expression | the definition's body carries no dimension the reader's `dims` do not name | | a given entry no fragment introduces | it stays under `given:` until a host model provides it | | a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that lands on a name no fragment defines, reads or uses is refused | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried | | `version` | every fragment is written against the same one | | `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 84295e50..0f17f05c 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -20,7 +20,8 @@ * **A dimension or a relation every fragment may declare**, and the ones that do have to say the same thing about it. Prose is not a claim, so two - descriptions of one dimension agree, and the first fragment's is carried. + descriptions of one dimension agree, and the first in the fragments' name + order is carried: the order they are passed in reaches no description. * **Every other declaration is owned.** A name two fragments declare is refused, both named. * **The objectives are summed**, each term in parentheses, in the fragments' @@ -37,7 +38,8 @@ * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, - and a name read as one kind and introduced as another is refused. + and a name read as one kind and introduced as another is refused. A + reader's description fills a declaration its owner left undescribed. Two fragments that both read a name have to read it over one frame. What no fragment introduces stays under ``given:`` until a host model provides it. @@ -209,7 +211,8 @@ def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> Equality of the claims rather than "the same or less": between peers neither declaration is the one being restated, so a field only one of them - writes is a difference nothing settles. + writes is a difference nothing settles. Prose is no claim: the first + description in the fragments' name order is carried. """ merged: dict[str, object] = {} for name, sections in read.items(): @@ -222,9 +225,24 @@ def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> f'them a name of its own.' ) merged.setdefault(key, block) + for key, block in merged.items(): + if said := _said(read, section, key): + merged[key] = {**_mapping(block), 'description': said} return merged +def _said(read: Mapping[str, dict[str, object]], section: str, key: str) -> object: + """The first description of *key* under *section* in the fragments' name order, which no argument order changes.""" + return next( + ( + said + for name in sorted(read) + if (said := _mapping(_mapping(read[name].get(section)).get(key)).get('description')) + ), + None, + ) + + def _claims(block: object) -> object: """*block* without its prose, which is what the declaration says rather than a remark about it.""" return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block @@ -270,7 +288,8 @@ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[s Two readings of one name agree on the frame, compared as a set. A term is the fragment's own and is summed by [`_summed`][], so it is no claim about - the name; prose is not one either, so the first description is carried. + the name; prose is not one either, so the first description in the + fragments' name order is carried. """ agreed: dict[str, dict[str, object]] = {} for name, given in asked.items(): @@ -286,7 +305,8 @@ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[s f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' f'files read one name over one frame: make the two identical.' ) - held['description'] = held.get('description') or entry.get('description') + for key, entry in agreed.items(): + entry['description'] = _said(asked, 'expressions', key) return agreed @@ -411,7 +431,9 @@ def _folded( Where the sibling is in the composition the expectation is checked and then dropped, so the composed spec declares the name once. A given expression is checked against the frame of the composed body: the body - carries no dimension the reader does not state. + carries no dimension the reader does not state. A reader's description + fills a declaration its owner left undescribed, and yields to one the + owner wrote. """ asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} left: dict[str, object] = {} @@ -429,6 +451,10 @@ def _folded( f'says the same as the declaration it is folded into, or less: restate the frame as the ' f'introducer declares it, or leave the field out.' ) + if key in introduced and (said := _mapping(block).get('description')): + owned = _as_mapping(introduced[key]) + if not owned.get('description'): + introduced[key] = {**owned, 'description': said} kept = {key: block for key, block in agreed.items() if key not in introduced} if kept: left[kind] = kept diff --git a/tests/test_composition.py b/tests/test_composition.py index 6d80bf46..a5e291f0 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -159,16 +159,58 @@ def test_a_disagreement_between_fragments_is_refused(fragments, says): assert all(f"'{name}'" in message for name in fragments), 'a disagreement names both fragments' -def test_two_descriptions_of_one_dimension_agree_and_the_first_is_carried(): - """Prose is not a claim, so two fragments describing one dimension in their own words agree about it.""" - first = {**SUPPLY, 'dimensions': {**SUPPLY['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'an hour'}}} - second = {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'a step'}}} - composed = merge({'supply': first, 'demand': second}) - assert composed.dimensions['snapshot'].description == 'an hour', ( - "the claim is carried whole, under the first fragment's wording of the prose" +def _said(fragment: dict[str, object], section: str, name: str, words: str) -> dict[str, object]: + """*fragment* with the declaration *name* under *section* described as *words*.""" + block = copy.deepcopy(fragment) + entries = block[section] if section != 'given' else block['given']['variables'] + entries[name] = {**entries[name], 'description': words} + return block + + +#: Two fragments that word one declaration differently, and the wording the +#: first fragment in name order gives: `demand` sorts before `supply`. +WORDED = [ + pytest.param('dimensions', 'snapshot', id='a-shared-dimension'), + pytest.param('given', 'flow', id='a-reading-nothing-introduces'), +] + + +@pytest.mark.parametrize(('section', 'name'), WORDED) +def test_prose_two_fragments_word_apart_is_the_first_in_name_order(section, name): + """Prose is not a claim, so two wordings agree; which one is carried is decided by name, not by argument order. + + Merging once passed the wording of whichever fragment came first in the call. + """ + supply, demand = _said(SUPPLY, section, name, 'an hour'), _said(DEMAND, section, name, 'a step') + one = merge({'supply': supply, 'demand': demand}) + other = merge({'demand': demand, 'supply': supply}) + assert one == other, 'the order the fragments are passed in reaches no field, prose included' + carried = one.dimensions[name] if section == 'dimensions' else one.given.variables[name] + assert carried.description == 'a step', "the claim is carried whole, under the wording of 'demand'" + + +def test_a_peer_s_description_is_carried_where_the_first_in_name_order_has_none(): + supply = _said(SUPPLY, 'dimensions', 'snapshot', 'an hour') + assert merge({'demand': DEMAND, 'supply': supply}).dimensions['snapshot'].description == 'an hour', ( + "'demand' sorts first and says nothing, so the wording of 'supply' is carried" ) +@pytest.mark.parametrize( + ('owner', 'carried'), + [ + pytest.param(None, 'what a port puts into its bus', id='the-owner-says-nothing'), + pytest.param('a flow', 'a flow', id='the-owner-s-own-wins'), + ], +) +def test_a_reader_s_description_fills_a_declaration_that_has_none(owner, carried): + """A reader's words about a name were dropped when the name was folded, even where the owner wrote none.""" + surface = _said(SURFACE, 'variables', 'flow', owner) if owner else SURFACE + supply = _said(SUPPLY, 'given', 'flow', 'what a port puts into its bus') + composed = merge({'surface': surface, 'supply': supply, 'demand': DEMAND}) + assert composed.variables['flow'].description == carried + + def test_the_objectives_are_summed_each_term_parenthesised(): """`a + b * k` reassociates, so an unparenthesised join composes a different objective.""" priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} diff --git a/tests/test_terms.py b/tests/test_terms.py index 0728de01..21060a96 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -216,6 +216,17 @@ def test_the_sum_takes_the_readers_description(): assert composed.program.expressions['injection'].description == INJECTION +def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording_in_name_order(): + """The sum once took the wording of whichever reader was passed first.""" + capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': FRAME, 'description': 'a cap'}}}} + capped = {**capped, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + for fragments in ( + {'capped': capped, 'balance': BALANCE, 'fleet': FLEET}, + {'balance': BALANCE, 'capped': capped, 'fleet': FLEET}, + ): + assert merge(fragments).expressions['injection'].description == INJECTION, "the wording of 'balance'" + + def test_a_composed_spec_takes_more_terms_in_a_second_merge(): """A composed definition is one a fragment wrote, so a later term adds to it like any other.""" shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) From 1339df3379db15692a03d7b1113287d861640fb0 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:30:41 +0200 Subject: [PATCH 63/75] refactor(language): a term prints as the definition it is, and one rule folds every reading (#738) * refactor(language): a term prints as the definition it is, and merge takes the fragments in name order once Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB * Add the changelog link for #738 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB * Retitle the changelog line: the name-order rule is #739's Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + docs/reference/language/declarations.md | 5 +- docs/reference/notation.md | 20 --- docs/several-files.md | 10 +- src/mathspec/composition.py | 223 +++++++++++------------- src/mathspec/lowering.py | 3 +- src/mathspec/program.py | 2 +- src/mathspec/typesetting/__init__.py | 18 +- src/mathspec/typesetting/format.py | 2 - src/mathspec/typesetting/legend.py | 2 +- src/mathspec/typesetting/walk.py | 30 +--- tests/fixtures.py | 12 ++ tests/test_given.py | 16 +- tests/test_terms.py | 82 ++++----- tests/typesetting/golden/latex.out | 10 +- tests/typesetting/golden/markdown.out | 19 -- tests/typesetting/golden/model.yaml | 7 - tests/typesetting/golden/typst.out | 10 +- tests/typesetting/test_cli.py | 7 +- tests/typesetting/test_declaration.py | 2 +- tests/typesetting/test_walk.py | 2 +- tools/notation.py | 1 - 22 files changed, 185 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf54364..5b81dc6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) - feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732)) +- refactor(language): a term prints as the definition it is, and one rule folds every reading ([#738](https://github.com/energy-models/mathspec/pull/738)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) - fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out ([#739](https://github.com/energy-models/mathspec/pull/739)) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index d2acea4f..74ad2e68 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -289,9 +289,8 @@ Each file loads alone. The term names an expression the file declares, and that expression does not read the name it adds to, directly or through another name. It carries no dimension the entry does not state, and it is held to degree two, as what reads the sum is. All of this is checked at load. -The typeset legend lists the entry under _Given_, and the math prints the term -under _Definitions_ as its own line and as `injection = ⋯ + generation`, the -dots standing for what the other files add. +The typeset legend lists the entry under _Given_ and names the term, and the +math prints the term under _Definitions_ as its own line. [`merge`](../../howto/compose.md#a-library-of-components) defines the name as the definition one fragment writes under `expressions:`, if any, plus every diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 9c7fbc9e..030bc436 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -117,17 +117,10 @@ parameters: | $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | -#### Given - -| Symbol | Meaning | -|---|---| -| $`\mathit{withdrawal}`$ | `withdrawal` over $`\mathcal{T} \times \mathcal{G}`$, an expression this file adds a term to | - #### Definitions | Symbol | Meaning | |---|---| -| $`\mathrm{leak}`$ | `leak` over $`\mathcal{G}`$ | | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | @@ -739,19 +732,6 @@ expressions: \mathit{marginal\_price}_{t,b} = \lambda_{\mathrm{balance},t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` -#### Term a given expression names - -the term: an ordinary named expression, which a given expression names - -```yaml -expressions: - leak: -eta -``` - -```math -\mathrm{leak}_{g} = -\mathrm{eta}_{g} \qquad \forall\, g \in \mathcal{G} -``` - ### Shifts #### Cyclic and acyclic shift diff --git a/docs/several-files.md b/docs/several-files.md index 68544688..53d8b6ac 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -110,8 +110,8 @@ import mathspec as ms print(ms.to_markdown('generators.yaml', legend=False)) ``` -The term prints as its own definition, and then after dots, which stand for -what the other files put in: +The term prints as its own definition, and the legend, left out here, says +what it adds to: !!! example "Rendered output" @@ -131,12 +131,6 @@ what the other files put in: \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`injection`** - - ```math - \mathit{injection}_{t,b} = \cdots + \mathit{generation}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} - ``` - #### Variable domains **`dispatch`** diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 0f17f05c..3b02849d 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -40,8 +40,9 @@ A given expression's body may carry no dimension its reader does not state, and a name read as one kind and introduced as another is refused. A reader's description fills a declaration its owner left undescribed. - Two fragments that both read a name have to read it over one frame. What no - fragment introduces stays under ``given:`` until a host model provides it. + Two fragments that both read a name have to read it over one frame, as a + set. What no fragment introduces stays under ``given:`` until a host model + provides it. A patch says only what it changes, because declarations are laid over a field at a time:: @@ -92,7 +93,7 @@ from mathspec.validation import to_spec if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Callable, Iterable from pathlib import Path #: The declarations that are the coordinate space rather than the math. A patch @@ -161,14 +162,17 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des if description is not None: merged['description'] = description for section in SHARED_SECTIONS: - if agreed := _agreed(read, section, _singular(section)): + if agreed := _agreed(read, section, _singular(section), 'give one of them a name of its own'): merged[section] = agreed - asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} - readings = _agreed_readings(asked) + asked = {name: _readings(sections) for name, sections in read.items()} + readings = { + kind: _agreed(asked, kind, label, 'read it over one frame', claims=_reading_claims) + for kind, label in GIVEN_KINDS.items() + } for section in OWNED_SECTIONS: if claimed := _claimed(read, section): merged[section] = claimed - if summed := _summed(read, loaded, _mapping(merged.get('expressions')), readings): + if summed := _summed(read, loaded, _mapping(merged.get('expressions')), readings['expressions']): merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} if given := _folded(read, merged, loaded, readings): merged['given'] = given @@ -206,23 +210,61 @@ def _author_of(read: Mapping[str, dict[str, object]], section: str, key: str) -> return next(name for name, sections in read.items() if key in _mapping(sections.get(section))) -def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> dict[str, object]: +def _said(read: Mapping[str, dict[str, object]], section: str, key: str) -> object: + """The first description of *key* under *section* in the fragments' name order, which no argument order changes.""" + return next( + ( + said + for name in sorted(read) + if (said := _mapping(_mapping(read[name].get(section)).get(key)).get('description')) + ), + None, + ) + + +def _claims(block: object) -> object: + """*block* without its prose, which is what the declaration says rather than a remark about it.""" + return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block + + +def _reading_claims(block: object) -> object: + """What a reading claims: its fields but the prose, with the frame as a set, since two files read one name over one frame however they list it.""" + claims = _mapping(_claims(block)) + return {**claims, 'dims': frozenset(cast('list[str]', claims.get('dims', [])))} + + +def _readings(sections: Mapping[str, object]) -> dict[str, object]: + """The ``given:`` block of one fragment, each entry without its term: what the fragment reads, apart from what it adds.""" + return { + kind: {key: {f: v for f, v in _mapping(entry).items() if f != 'term'} for key, entry in _mapping(group).items()} + for kind, group in _mapping(sections.get('given')).items() + } + + +def _agreed( + read: Mapping[str, dict[str, object]], + section: str, + label: str, + repair: str, + *, + claims: Callable[[object], object] = _claims, +) -> dict[str, object]: """One block every fragment may declare, peers that say the same thing folded together. Equality of the claims rather than "the same or less": between peers neither declaration is the one being restated, so a field only one of them - writes is a difference nothing settles. Prose is no claim: the first - description in the fragments' name order is carried. + writes is a difference nothing settles. *claims* says what a block claims; + a reading's frame is a set. Prose is not a claim, so the first description + in the fragments' name order is carried, whatever order they are passed in. """ merged: dict[str, object] = {} for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): - if key in merged and _claims(merged[key]) != _claims(block): + if key in merged and claims(merged[key]) != claims(block): raise LanguageError( f"fragments '{_author_of(read, section, key)}' and '{name}' say different things about " f'the {label} {key!r}: {merged[key]!r} against {block!r}. A declaration two fragments ' - f'share is one both say the same thing about: make the two identical, or give one of ' - f'them a name of its own.' + f'share is one both say the same thing about: make the two identical, or {repair}.' ) merged.setdefault(key, block) for key, block in merged.items(): @@ -231,23 +273,6 @@ def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> return merged -def _said(read: Mapping[str, dict[str, object]], section: str, key: str) -> object: - """The first description of *key* under *section* in the fragments' name order, which no argument order changes.""" - return next( - ( - said - for name in sorted(read) - if (said := _mapping(_mapping(read[name].get(section)).get(key)).get('description')) - ), - None, - ) - - -def _claims(block: object) -> object: - """*block* without its prose, which is what the declaration says rather than a remark about it.""" - return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block - - def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: """One block of owned declarations, a name claimed twice being the refusal.""" merged: dict[str, object] = {} @@ -283,33 +308,6 @@ def _adds(sections: Mapping[str, object], key: str) -> str | None: return next((target for target, entry in given.items() if _mapping(entry).get('term') == key), None) -def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: - """Every ``given: expressions:`` entry, the ones two fragments share folded together, without their terms. - - Two readings of one name agree on the frame, compared as a set. A term is - the fragment's own and is summed by [`_summed`][], so it is no claim about - the name; prose is not one either, so the first description in the - fragments' name order is carried. - """ - agreed: dict[str, dict[str, object]] = {} - for name, given in asked.items(): - for key, block in _mapping(given.get('expressions')).items(): - entry = {field: value for field, value in cast('dict[str, object]', block).items() if field != 'term'} - if key not in agreed: - agreed[key] = entry - continue - held = agreed[key] - if set(cast('list[str]', held['dims'])) != set(cast('list[str]', entry['dims'])): - raise LanguageError( - f"fragments '{_author_of(asked, 'expressions', key)}' and '{name}' say different things " - f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' - f'files read one name over one frame: make the two identical.' - ) - for key, entry in agreed.items(): - entry['description'] = _said(asked, 'expressions', key) - return agreed - - def _terms(read: Mapping[str, dict[str, object]], key: str) -> list[tuple[str, str]]: """Every term the fragments add to *key*, with the fragment that adds it, in the fragments' name order.""" found = [] @@ -324,7 +322,7 @@ def _summed( read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], defined: Mapping[str, object], - readings: Mapping[str, dict[str, object]], + readings: Mapping[str, object], ) -> dict[str, object]: """Every name a fragment adds a term to, defined as the definition plus the terms. @@ -336,7 +334,8 @@ def _summed( a reader wrote. """ summed: dict[str, object] = {} - for key, entry in readings.items(): + for key, reading in readings.items(): + entry = _mapping(reading) terms = _terms(read, key) if not terms: continue @@ -429,28 +428,17 @@ def _folded( A given declaration is what a fragment expects of a name a sibling owns. Where the sibling is in the composition the expectation is checked and - then dropped, so the composed spec declares the name once. A given - expression is checked against the frame of the composed body: the body - carries no dimension the reader does not state. A reader's description - fills a declaration its owner left undescribed, and yields to one the - owner wrote. + then dropped, so the composed spec declares the name once. A reader's + description fills a declaration its owner left undescribed, and yields to + one the owner wrote. """ - asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} left: dict[str, object] = {} - for kind, label in GIVEN_KINDS.items(): + for kind, agreed in readings.items(): introduced = _mapping(merged.get(kind)) - agreed = readings if kind == 'expressions' else _agreed(asked, kind, label) for key, block in agreed.items(): - _same_kind(asked, read, merged, kind, key) - if key in introduced and kind == 'expressions': - _within_frame(asked, read, key, block, _definer_frame(loaded, key)) - elif key in introduced and not _says_less(block, introduced[key]): - raise LanguageError( - f"fragment '{_author_of(asked, kind, key)}' reads the {label} {key!r} as {block!r}, where " - f"'{_author_of(read, kind, key)}' introduces it as {introduced[key]!r}. A given declaration " - f'says the same as the declaration it is folded into, or less: restate the frame as the ' - f'introducer declares it, or leave the field out.' - ) + _same_kind(read, merged, kind, key) + if key in introduced: + _fits(read, loaded, kind, key, block, introduced[key]) if key in introduced and (said := _mapping(block).get('description')): owned = _as_mapping(introduced[key]) if not owned.get('description'): @@ -461,6 +449,45 @@ def _folded( return left +def _reader_of(read: Mapping[str, dict[str, object]], kind: str, key: str) -> str: + """The first fragment reading *key* under ``given: {kind}:``.""" + return next(name for name, sections in read.items() if key in _mapping(_readings(sections).get(kind))) + + +def _fits( + read: Mapping[str, dict[str, object]], + loaded: Mapping[str, Spec], + kind: str, + key: str, + reading: object, + introduced: object, +) -> None: + """Refuse a reading that says more than the declaration it folds into. + + A reading states the frame its introducer declares, and every other field + it writes is the introducer's. A given expression is the one kind whose + frame may be wider than the composed body: a body over fewer dimensions + broadcasts, and the composed load refuses a row it would repeat. + """ + stated = set(cast('list[str]', _mapping(reading)['dims'])) + if kind == 'expressions': + frame = set(_definer_frame(loaded, key)) + fits = frame <= stated + else: + frame = set(cast('list[str]', _mapping(introduced)['dims'])) + fits = frame == stated + fields = {f: v for f, v in _mapping(_claims(reading)).items() if f != 'dims'} + if fits and all(_mapping(introduced).get(f) == v for f, v in fields.items()): + return + how = f'over {sorted(frame)}' if kind == 'expressions' else f'as {introduced!r}' + raise LanguageError( + f"fragment '{_reader_of(read, kind, key)}' reads the {GIVEN_KINDS[kind]} {key!r} as {reading!r}, where " + f"'{_author_of(read, kind, key)}' introduces it {how}. A given declaration says the same as the " + f'declaration it is folded into, or less: restate the frame as the introducer declares it, or leave ' + f'the field out.' + ) + + def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: """The frame of the composed body of *key*: the definition's, where a fragment writes one, with every term's.""" frame: set[str] = set() @@ -478,57 +505,19 @@ def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: READ_KINDS = ('parameters', 'variables', 'expressions') -def _same_kind( - asked: Mapping[str, dict[str, object]], - read: Mapping[str, dict[str, object]], - merged: Mapping[str, object], - kind: str, - key: str, -) -> None: +def _same_kind(read: Mapping[str, dict[str, object]], merged: Mapping[str, object], kind: str, key: str) -> None: """Refuse a given declaration whose name a sibling introduces as another kind of thing.""" if kind not in READ_KINDS: return for other in READ_KINDS: if other != kind and key in _mapping(merged.get(other)): raise LanguageError( - f"fragment '{_author_of(asked, kind, key)}' reads {key!r} as a {GIVEN_KINDS[kind]}, where " + f"fragment '{_reader_of(read, kind, key)}' reads {key!r} as a {GIVEN_KINDS[kind]}, where " f"'{_author_of(read, other, key)}' introduces it under '{other}:'. A given declaration reads a " f"name as the kind of thing its introducer declares: move it under 'given: {other}:'." ) -def _within_frame( - asked: Mapping[str, dict[str, object]], - read: Mapping[str, dict[str, object]], - key: str, - block: object, - frame: frozenset[str], -) -> None: - """Refuse a definition whose body carries a dimension the given expression's reader does not state. - - The reader's ``dims`` bounds what it reads. A body over fewer dimensions is - left to the composed spec's load, which refuses a row it would repeat and - accepts one another term carries the dimension through. - """ - stated = cast('list[str]', _mapping(block)['dims']) - if extra := sorted(set(frame) - set(stated)): - raise LanguageError( - f"fragment '{_author_of(asked, 'expressions', key)}' reads the given expression {key!r} over " - f"{sorted(stated)}, where '{_author_of(read, 'expressions', key)}' defines it over {sorted(frame)}. " - f'A given expression is read over at most the frame its reader states, and this body carries ' - f'{extra} beyond it: add {extra} to the dims of the given entry.' - ) - - -def _says_less(reader: object, introducer: object) -> bool: - """Whether every claim *reader* makes is one *introducer* makes too. - - Both come from a loaded spec's ``to_dict``, which writes every default - out, so a field one of them left to its default is still a claim here. - """ - return all(_mapping(introducer).get(key) == value for key, value in _mapping(_claims(reader)).items()) - - def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object] | None: """Every fragment's objective summed, each term in parentheses, or ``None`` where none declares one. diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 611664db..7ed96ca2 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -125,7 +125,7 @@ def lower(schema: Spec) -> Program: if node is not None: entries[ename] = node - terms: dict[str, Expression] = {} + terms: dict[str, Named] = {} for gname, gdef in schema.given.expressions.items(): if gdef.term is None: continue @@ -140,6 +140,7 @@ def lower(schema: Spec) -> Program: term = resolve_expression_text(gdef.term, ns, context, errors, ceiling=2) if term is None: continue + assert isinstance(term, Named), 'a term is a name, and a name resolves to the entry it names' if any(isinstance(node, Variable) and node.name == gname for node in walk(term)): errors.append( f"{context}: its term {gdef.term!r} reads '{gname}', the sum the term adds to, so the sum would " diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 24c1ba67..acb16f1c 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -625,7 +625,7 @@ class GivenDeclaration: #: The term this program adds to a given expression, or ``None`` where it #: only reads the name: the [`Named`][] node of the entry the term names, #: read over at most ``dims``. - term: Expression | None = None + term: Named | None = None @dataclass(frozen=True) diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 3824412a..6a85ebd8 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -176,9 +176,8 @@ def typeset_declaration( """Render one declaration as the bare line the document prints for it. The line the whole-spec render prints for it — a named expression's - definition, the term this file adds to a given expression, a constraint, - an assumption, a ``piecewise:`` curve, or a variable's domain, quantifier - included — + definition, a constraint, an assumption, a ``piecewise:`` curve, or a + variable's domain, quantifier included — with no document, label, equation number or math delimiters around it, for a math context the caller lays out: a docstring, a table cell. A line on its own has no Definitions section beside it, so the plain named @@ -188,8 +187,7 @@ def typeset_declaration( Args: spec: Anything [`mathspec.to_spec`][] accepts, or a [`Program`][]. name: A named expression, constraint, assumption, ``piecewise:`` - block or variable the spec declares, or a given expression it adds - a term to. + block or variable the spec declares. fmt: What spells the math — a key of [`FORMATS`][]. symbols: How names print; see [`typeset`][]. inline_expressions: Substitute the plain named expressions the line uses, so it @@ -203,17 +201,17 @@ def typeset_declaration( Raises: ValueError: *fmt* names no format. LanguageError: A spec that does not compile; it does not print. - SchemaError: *name* is declared as none of the six, as two — a - constraint may share a variable's name — or under ``given:`` with - no term, which prints in the legend rather than as a line; or a - symbol table entry names nothing in the spec. + SchemaError: *name* is declared as none of the five, as two — a + constraint may share a variable's name — or under ``given:``, which + prints in the legend rather than as a line; or a symbol table entry + names nothing in the spec. """ walk = _walk(spec, fmt, symbols, inline_expressions=inline_expressions) given = walk.program.given givens = { 'parameter': given.parameters, 'variable': given.variables, - 'expression': {name: block for name, block in given.expressions.items() if block.term is None}, + 'expression': given.expressions, 'constraint': given.constraints, } given_kind = next((kind for kind, group in givens.items() if name in group), None) diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index 558c9703..fe888114 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -62,7 +62,6 @@ 'dual', 'minimize', 'maximize', - 'ellipsis', ] #: Every operator a walk can emit, by the name the walk uses for it, with its @@ -109,7 +108,6 @@ 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), 'maximize': (r'\max', 'max'), - 'ellipsis': (r'\cdots', 'dots.c'), } #: The set form, for the test pinning each format's table against the vocabulary. diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 416342cc..b551c336 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -173,7 +173,7 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + ( - 'an expression this file adds a term to' + f'an expression this file adds {fmt.mono(block.term.name)} to' if block.term is not None else 'an expression another file defines' ), diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 435282e8..7f3b4f40 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -696,7 +696,7 @@ def _constraint(self, name: str) -> Line: ) def _definitions(self) -> list[Line]: - """One line per named expression, in declaration order, defining it, then one per term this program adds. + """One line per named expression, in declaration order, defining it. A use prints the symbol and the block prints here, as a paper states a quantity it names. Every declared one prints, used or not. Inlining @@ -704,28 +704,7 @@ def _definitions(self) -> list[Line]: no single body to substitute, and an entry the math never reads has nowhere to be substituted *into*, so both still print. """ - return [*(self.definition(name) for name in self.defined()), *(self.term(name) for name in self.terms())] - - def terms(self) -> list[str]: - """The given expressions this program adds a term to, in declaration order.""" - return [name for name, given in self.program.given.expressions.items() if given.term is not None] - - def term(self, name: str) -> Line: - """The line for the term this program adds to a given expression: ``symbol = ⋯ + term`` over the entry's frame. - - The dots stand for what the other files put in. - """ - given = self.program.given.expressions[name] - assert given.term is not None, 'a line prints for a term, and not for a name this program only reads' - frame = list(given.dims) - ctx = self._context(frame) - rendered = self._expression(given.term, ctx, need=_PRECEDENCE['+']) - return Line( - label=name, - left=ctx.indexed(self.symbols.name[name], frame), - right=f'{self._op("equal")} {self._op("ellipsis")} {self._op("plus")} {rendered}', - condition=self._quantifier(frame, ''), - ) + return [self.definition(name) for name in self.defined()] def defined(self) -> list[str]: """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining. @@ -753,7 +732,7 @@ def definition(self, name: str) -> Line: ) def line(self, name: str) -> Line: - """The one line *name* prints as: a named expression, a term this program adds, a constraint, an assumption, a curve, or a variable's domain. + """The one line *name* prints as: a named expression, a constraint, an assumption, a curve, or a variable's domain. An assumption is looked up where the document prints it from, so a condition a curve's method states is a line a reader can ask for @@ -767,7 +746,6 @@ def line(self, name: str) -> Line: program = self.program kinds = { 'named expression': (program.expressions, self.definition), - 'term': (dict.fromkeys(self.terms()), self.term), 'constraint': (program.constraints, self._constraint), 'assumption': (program.assumptions, self._assumption), 'curve': (program.piecewise, self._piecewise), @@ -777,7 +755,7 @@ def line(self, name: str) -> Line: if not found: everything = {n for group, _ in kinds.values() for n in group} msg = ( - f"'{name}' is not a named expression, term, constraint, assumption, curve or variable. " + f"'{name}' is not a named expression, constraint, assumption, curve or variable. " f'{did_you_mean(name, everything)}' ) raise SchemaError(msg) diff --git a/tests/fixtures.py b/tests/fixtures.py index 9a510b21..dcc38478 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -59,6 +59,18 @@ 'variables': {'p': {'dims': ['g']}, 'q': {'dims': ['g', 'h']}, 'r': {'dims': ['h']}}, } +#: The frame a bus balance runs over, and the balance itself: a fragment that +#: reads `injection` under `given:` and defines none of the injections. The +#: tests of `given:` and of terms both compose it with component fragments. +BUS_DIMS: dict[str, Any] = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} +BUS_FRAME = ['snapshot', 'bus'] +INJECTION = 'what the components put into a bus' +BALANCE: dict[str, Any] = { + 'dimensions': BUS_DIMS, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': INJECTION}}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, +} + def varied(base: dict[str, Any], **patch: Any) -> dict[str, Any]: """A deep copy of ``base`` with dotted paths replaced, missing parents created. diff --git a/tests/test_given.py b/tests/test_given.py index 0961d42f..a19aa75f 100644 --- a/tests/test_given.py +++ b/tests/test_given.py @@ -17,6 +17,7 @@ import pytest from mathspec import FORMATS, LanguageError, advice, merge, to_markdown, to_spec, typeset +from tests.fixtures import BALANCE #: One component file: it pins the flow at its own port, and the column it #: pins belongs to another fragment. @@ -446,16 +447,6 @@ def test_the_advice_names_a_given_parameter_as_data_a_consumer_binds(): # a named expression another file defines # --------------------------------------------------------------------------- -#: A balance file: it clears each bus of what every component injects, and -#: defines none of the injections. -BALANCE = { - 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, - 'given': { - 'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'description': 'what the components put into a bus'}} - }, - 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, -} - #: A component file that defines the injection. INJECTOR = { 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, @@ -514,7 +505,10 @@ def test_a_definition_over_a_dimension_the_reader_does_not_state_is_refused(): """The reader's frame bounds what it reads, so a body carrying more is a disagreement `merge` names.""" narrow = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} - with pytest.raises(LanguageError, match=r"'balance' reads the given expression 'injection' over \['bus'\]"): + with pytest.raises( + LanguageError, + match=r"'balance' reads the given expression 'injection' as .*'injector' introduces it over \['bus', 'snapshot'\]", + ): merge({'balance': narrow, 'injector': INJECTOR}) diff --git a/tests/test_terms.py b/tests/test_terms.py index 21060a96..f0f6f48a 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -28,24 +28,14 @@ typeset_declaration, ) from mathspec.program import Named, Variable, walk - -DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} -FRAME = ['snapshot', 'bus'] -INJECTION = 'what the components put into a bus' - -#: The balance: it reads `injection` over its frame, and adds nothing to it. -BALANCE = { - 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': FRAME, 'description': INJECTION}}}, - 'constraints': {'balance': {'dims': FRAME, 'expression': 'injection == 0'}}, -} +from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION #: A generator fleet: what it puts in is its term. FLEET = { - 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, + 'dimensions': {**BUS_DIMS, 'generator': {'dtype': 'str'}}, 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, - 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'generator_injection'}}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'generator_injection'}}}, 'expressions': { 'generator_injection': { 'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', @@ -57,27 +47,27 @@ #: A demand, subtracting what it draws. DEMAND = { - 'dimensions': DIMS, - 'parameters': {'load': {'dims': FRAME}}, - 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'demand_injection'}}}, + 'dimensions': BUS_DIMS, + 'parameters': {'load': {'dims': BUS_FRAME}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}}}, 'expressions': {'demand_injection': '-load'}, } #: A store, added in a second merge. STORAGE = { - 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'dimensions': {**BUS_DIMS, 'store': {'dtype': 'str'}}, 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, - 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'store_injection'}}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'store_injection'}}}, 'expressions': {'store_injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, } #: A network that defines the injection itself, as its slack, and reads it. NETWORK = { - 'dimensions': DIMS, - 'variables': {'slack': {'dims': FRAME}}, + 'dimensions': BUS_DIMS, + 'variables': {'slack': {'dims': BUS_FRAME}}, 'expressions': {'injection': {'expression': 'slack', 'description': 'the slack, and what the components add'}}, - 'constraints': {'balance': {'dims': FRAME, 'expression': 'injection == 0'}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } @@ -85,7 +75,7 @@ def _demand(term: str = 'demand_injection', body: object = '-load', **fields: ob """A demand whose `injection` entry names *term*, with *body* as `demand_injection` and *fields* on the entry.""" return { **DEMAND, - 'given': {'expressions': {'injection': {'dims': FRAME, 'term': term, **fields}}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': term, **fields}}}, 'expressions': {'demand_injection': body}, } @@ -110,7 +100,7 @@ def test_a_term_is_read_by_the_math(): def test_a_contributor_reads_the_name_as_the_whole_sum(): """Alone and composed the file reads one thing, so nothing has to refuse a file that adds and reads.""" - reads = {**FLEET, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + reads = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} assert to_spec(reads).program.constraints['capped'].dims == ('snapshot', 'bus') composed = merge({'balance': BALANCE, 'fleet': reads, 'demand': DEMAND}) assert composed.constraints['capped'].expression == 'injection <= 10' @@ -218,8 +208,8 @@ def test_the_sum_takes_the_readers_description(): def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording_in_name_order(): """The sum once took the wording of whichever reader was passed first.""" - capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': FRAME, 'description': 'a cap'}}}} - capped = {**capped, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} + capped = {**capped, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} for fragments in ( {'capped': capped, 'balance': BALANCE, 'fleet': FLEET}, {'balance': BALANCE, 'capped': capped, 'fleet': FLEET}, @@ -240,7 +230,7 @@ def test_a_cased_term_is_added_like_any_other(): """The sum names the term, so how the term is written is its own file's business.""" cased = _demand( body={ - 'dims': FRAME, + 'dims': BUS_FRAME, 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, 'otherwise': '0', } @@ -276,7 +266,7 @@ def test_terms_that_land_on_no_name_are_refused(fragments, message): def test_a_term_lands_on_a_name_a_contributor_s_own_math_uses(): - capped = {**FLEET, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}} + capped = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} composed = merge({'fleet': capped, 'demand': DEMAND}) assert composed.program.expressions['injection'].in_math @@ -288,10 +278,10 @@ def test_one_term_alone_is_its_name(): def test_two_definitions_collide_and_the_message_names_the_term(): other = { - 'dimensions': DIMS, - 'variables': {'other_slack': {'dims': FRAME}}, + 'dimensions': BUS_DIMS, + 'variables': {'other_slack': {'dims': BUS_FRAME}}, 'expressions': {'injection': 'other_slack'}, - 'constraints': {'other_balance': {'dims': FRAME, 'expression': 'injection == 0'}}, + 'constraints': {'other_balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } with pytest.raises(LanguageError) as raised: merge({'network': NETWORK, 'other': other}) @@ -303,7 +293,7 @@ def test_two_definitions_collide_and_the_message_names_the_term(): def test_two_terms_of_one_name_collide(): """A term is an ordinary named expression, so two fragments name theirs apart.""" twin = {**FLEET, 'expressions': {'demand_injection': FLEET['expressions']['generator_injection']}} - twin = {**twin, 'given': {'expressions': {'injection': {'dims': FRAME, 'term': 'demand_injection'}}}} + twin = {**twin, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}}}} with pytest.raises( LanguageError, match=r"both declare the expression 'demand_injection', which a fragment adds to 'injection' as a term\. " @@ -315,9 +305,9 @@ def test_two_terms_of_one_name_collide(): def test_a_cased_definition_a_term_adds_to_is_refused(): cased = { **NETWORK, - 'parameters': {'on': {'dims': FRAME, 'dtype': 'bool'}}, + 'parameters': {'on': {'dims': BUS_FRAME, 'dtype': 'bool'}}, 'expressions': { - 'injection': {'dims': FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} + 'injection': {'dims': BUS_FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} }, } with pytest.raises(LanguageError, match=r"'cased' defines 'injection' as `cases:`, and fragment 'demand' adds"): @@ -340,12 +330,13 @@ def test_a_term_over_fewer_dimensions_merges_where_another_carries_the_rest(): def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): wide = { **NETWORK, - 'dimensions': {**DIMS, 'carrier': {'dtype': 'str'}}, - 'variables': {'slack': {'dims': [*FRAME, 'carrier']}}, + 'dimensions': {**BUS_DIMS, 'carrier': {'dtype': 'str'}}, + 'variables': {'slack': {'dims': [*BUS_FRAME, 'carrier']}}, } - wide = {**wide, 'constraints': {'balance': {'dims': [*FRAME, 'carrier'], 'expression': 'injection == 0'}}} + wide = {**wide, 'constraints': {'balance': {'dims': [*BUS_FRAME, 'carrier'], 'expression': 'injection == 0'}}} with pytest.raises( - LanguageError, match=r"'demand' reads the given expression 'injection' over \['bus', 'snapshot'\]" + LanguageError, + match=r"'demand' reads the given expression 'injection' as .*'network' introduces it over \['bus', 'carrier', 'snapshot'\]", ): merge({'network': wide, 'demand': DEMAND}) @@ -362,23 +353,18 @@ def test_a_patch_changes_a_term_by_its_name_and_null_drops_it(): # --------------------------------------------------------------------------- -def test_the_legend_says_the_file_adds_a_term(): +def test_the_legend_names_the_term_the_file_adds(): given = to_markdown(DEMAND).split('#### Given')[1] - assert 'an expression this file adds a term to' in given - - -def test_the_term_prints_after_dots_that_stand_for_the_other_files(): - assert typeset_declaration(FLEET, 'injection', 'latex', inline_expressions=False) == ( - r'\mathit{injection}_{t,b} = \cdots + \mathit{generator\_injection}_{t,b} ' - r'\qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' - ) + assert 'an expression this file adds `demand_injection` to' in given -def test_a_reading_with_no_term_still_prints_no_line_of_its_own(): +@pytest.mark.parametrize('spec', [pytest.param(BALANCE, id='a-reader'), pytest.param(FLEET, id='a-contributor')]) +def test_a_given_expression_prints_no_line_of_its_own(spec): + """The term prints as the definition it is; the name it adds to prints in the legend, with or without a term.""" with pytest.raises( LanguageError, match=r"'injection' is a given expression, and a given declaration prints no line" ): - typeset_declaration(BALANCE, 'injection', 'latex') + typeset_declaration(spec, 'injection', 'latex') def test_the_composed_sum_prints_its_terms_by_name(): diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index bb55d1ec..c69496c8 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -56,14 +56,8 @@ \item[{$\mathit{warm}$}] \texttt{warm} over $\mathcal{T} \times \mathcal{G}$ \end{description} -\paragraph{Given} -\begin{description} -\item[{$\mathit{withdrawal}$}] \texttt{withdrawal} over $\mathcal{T} \times \mathcal{G}$, an expression this file adds a term to -\end{description} - \paragraph{Definitions} \begin{description} -\item[{$\mathrm{leak}$}] \texttt{leak} over $\mathcal{G}$ \item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ \item[{$\mathit{spend}$}] \texttt{spend} over $\mathcal{T}$ --- what a snapshot's dispatch costs \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) @@ -147,13 +141,11 @@ \paragraph{Definitions} \begin{align} -\text{leak} && \mathrm{leak}_{g} & = -\mathrm{eta}_{g} && \forall\, g \in \mathcal{G} \\ \text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ -\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{withdrawal} && \mathit{withdrawal}_{t,g} & = \cdots + \mathrm{leak}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \end{align} \paragraph{Variable domains} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 15c8672e..9d724f0f 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -54,17 +54,10 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | -#### Given - -| Symbol | Meaning | -|---|---| -| $`\mathit{withdrawal}`$ | `withdrawal` over $`\mathcal{T} \times \mathcal{G}`$, an expression this file adds a term to | - #### Definitions | Symbol | Meaning | |---|---| -| $`\mathrm{leak}`$ | `leak` over $`\mathcal{G}`$ | | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | @@ -401,12 +394,6 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g #### Definitions -**`leak`** - -```math -\mathrm{leak}_{g} = -\mathrm{eta}_{g} \qquad \forall\, g \in \mathcal{G} -``` - **`spend_cap`** ```math @@ -437,12 +424,6 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` -**`withdrawal`** - -```math -\mathit{withdrawal}_{t,g} = \cdots + \mathrm{leak}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - #### Variable domains **`p`** diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 921693af..ebe77903 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -138,14 +138,7 @@ piecewise: - [p, bp_x] - [heat, bp_heat, "<="] -given: - expressions: - withdrawal: # a name another file defines, which this file adds a term to: the term prints after dots - dims: [snapshot, generator] - term: leak - expressions: - leak: -eta # the term: an ordinary named expression, which a given expression names spend_cap: cost * 2 # a data-only entry, so a where may compare it spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 3217865f..cd926e14 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -46,11 +46,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("op_cost")$: `op_cost` over $cal(T) times cal(G)$ / $italic("warm")$: `warm` over $cal(T) times cal(G)$ -== Given -/ $italic("withdrawal")$: `withdrawal` over $cal(T) times cal(G)$, an expression this file adds a term to - == Definitions -/ $upright("leak")$: `leak` over $cal(G)$ / $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs / $italic("lcoe")$: `lcoe` (scalar) @@ -131,13 +127,11 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in == Definitions #set math.equation(numbering: "(1)") -$ upright("leak") & upright("leak")_(g) & = -upright("eta")_(g) & forall g in cal(G) \ - upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ +$ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ - upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) \ - upright("withdrawal") & italic("withdrawal")_(t,g) & = dots.c + upright("leak")_(g) & forall t in cal(T), g in cal(G) $ + upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ == Variable domains #set math.equation(numbering: "(1)") diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index b6eb3194..f3c4a0a6 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -71,12 +71,9 @@ def test_check_prints_nothing_for_a_clean_file(capsys): def test_check_accepts_the_model_that_carries_every_construct(capsys): """The golden model exercises every operator and every edge policy, so `check` accepting it is the claim that the whole language loads through - one door — and that only the term it adds to another file's name draws advice.""" + one door — and that none of it draws advice.""" assert front.main(['check', str(golden.MODEL)]) == 0, 'the whole language loads' - out, err = capsys.readouterr() - assert err == '' - assert out.startswith("expression 'withdrawal' is read here and declared elsewhere, and this file adds a term") - assert out.count('\n') == 1, 'one note, one line, and nothing else on the model draws advice' + assert capsys.readouterr() == ('', ''), 'no advice, no output' def test_check_reads_a_curve_as_written(capsys): diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index a9791fca..321fd3fa 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -142,7 +142,7 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ pytest.param( PLAIN, 'spent', - r"'spent' is not a named expression, term, constraint, assumption, curve or variable.*spend", + r"'spent' is not a named expression, constraint, assumption, curve or variable.*spend", id='a-near-miss', ), pytest.param(PLAIN, 'objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 8d101f18..c9d98895 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -533,7 +533,7 @@ def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" schema = to_spec(golden.MODEL) - computed = set(schema.variables) | set(schema.given.expressions) | chosen_expressions(schema.program) + computed = set(schema.variables) | chosen_expressions(schema.program) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( f'{sorted(italic - computed)} print italic and are neither chosen by the solver nor read off its ' diff --git a/tools/notation.py b/tools/notation.py index 8217e504..50510746 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -109,7 +109,6 @@ 'capped': 'Named expression in a condition', 'lcoe': 'Reported expression', 'marginal_price': 'Dual of a constraint', - 'leak': 'Term a given expression names', }, 'Shifts': { 'ramp': 'Cyclic and acyclic shift', From 2641a3f05372312463427c7599d71d3fa473171f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:03:25 +0000 Subject: [PATCH 64/75] fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- .prettierignore | 1 + CHANGELOG.md | 1 + docs/examples/index.md | 3 +- docs/examples/pypsa.md | 693 ++++++++++++++-- docs/examples/pypsa/carrier.md | 107 +++ docs/examples/pypsa/cost.md | 129 +++ docs/examples/pypsa/generator.md | 516 ++++++++++++ docs/examples/pypsa/generator_commitment.md | 491 +++++++++++ docs/examples/pypsa/generator_maintenance.md | 418 ++++++++++ docs/examples/pypsa/generator_ramping.md | 390 +++++++++ docs/examples/pypsa/global_constraints.md | 267 ++++++ docs/examples/pypsa/index.md | 89 ++ docs/examples/pypsa/line.md | 474 +++++++++++ docs/examples/pypsa/link.md | 557 +++++++++++++ docs/examples/pypsa/link_commitment.md | 491 +++++++++++ docs/examples/pypsa/link_maintenance.md | 418 ++++++++++ docs/examples/pypsa/link_ramping.md | 390 +++++++++ docs/examples/pypsa/load.md | 107 +++ docs/examples/pypsa/network.md | 68 ++ docs/examples/pypsa/power_flow.md | 63 ++ docs/examples/pypsa/process.md | 510 ++++++++++++ docs/examples/pypsa/process_commitment.md | 491 +++++++++++ docs/examples/pypsa/process_maintenance.md | 418 ++++++++++ docs/examples/pypsa/process_ramping.md | 390 +++++++++ docs/examples/pypsa/security.md | 262 ++++++ docs/examples/pypsa/settings.md | 203 +++++ docs/examples/pypsa/storage_unit.md | 828 +++++++++++++++++++ docs/examples/pypsa/store.md | 643 ++++++++++++++ docs/examples/pypsa/transformer.md | 447 ++++++++++ examples/pypsa.yaml | 256 ++++-- examples/pypsa/generator.yaml | 18 +- examples/pypsa/line.yaml | 15 +- examples/pypsa/link.yaml | 17 +- examples/pypsa/network.yaml | 15 +- examples/pypsa/power_flow.yaml | 4 +- examples/pypsa/process.yaml | 9 +- examples/pypsa/{core.yaml => settings.yaml} | 0 examples/pypsa/storage_unit.yaml | 17 +- examples/pypsa/store.yaml | 17 +- mkdocs.yml | 26 + src/mathspec/typesetting/walk.py | 17 +- tests/test_pypsa_split.py | 14 +- tests/typesetting/test_declaration.py | 20 + tools/gallery.py | 108 ++- tools/pypsa_split.py | 305 ++----- 45 files changed, 10267 insertions(+), 456 deletions(-) create mode 100644 docs/examples/pypsa/carrier.md create mode 100644 docs/examples/pypsa/cost.md create mode 100644 docs/examples/pypsa/generator.md create mode 100644 docs/examples/pypsa/generator_commitment.md create mode 100644 docs/examples/pypsa/generator_maintenance.md create mode 100644 docs/examples/pypsa/generator_ramping.md create mode 100644 docs/examples/pypsa/global_constraints.md create mode 100644 docs/examples/pypsa/index.md create mode 100644 docs/examples/pypsa/line.md create mode 100644 docs/examples/pypsa/link.md create mode 100644 docs/examples/pypsa/link_commitment.md create mode 100644 docs/examples/pypsa/link_maintenance.md create mode 100644 docs/examples/pypsa/link_ramping.md create mode 100644 docs/examples/pypsa/load.md create mode 100644 docs/examples/pypsa/network.md create mode 100644 docs/examples/pypsa/power_flow.md create mode 100644 docs/examples/pypsa/process.md create mode 100644 docs/examples/pypsa/process_commitment.md create mode 100644 docs/examples/pypsa/process_maintenance.md create mode 100644 docs/examples/pypsa/process_ramping.md create mode 100644 docs/examples/pypsa/security.md create mode 100644 docs/examples/pypsa/settings.md create mode 100644 docs/examples/pypsa/storage_unit.md create mode 100644 docs/examples/pypsa/store.md create mode 100644 docs/examples/pypsa/transformer.md rename examples/pypsa/{core.yaml => settings.yaml} (100%) diff --git a/.prettierignore b/.prettierignore index a9db8434..9585e8fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,6 +24,7 @@ docs/examples/library/surface.md docs/examples/library/generator.md docs/examples/library/load.md docs/examples/library/composed.md +docs/examples/pypsa/*.md # The PyPSA reference scripts write this file; prettier would reformat what # they stamp, and the two would fight over it exactly as above. diff --git a/CHANGELOG.md b/CHANGELOG.md index 146d6200..729af0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - refactor(language): a term prints as the definition it is, and one rule folds every reading ([#738](https://github.com/energy-models/mathspec/pull/738)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) - fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out ([#739](https://github.com/energy-models/mathspec/pull/739)) +- fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment ## 0.2.0 (2026-09-25) diff --git a/docs/examples/index.md b/docs/examples/index.md index 160d513e..7a53f66d 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -19,6 +19,7 @@ Every spec is a file under `examples/` in the repository. composed page shows what `merge` returns. The PyPSA parity pages, from [PyPSA in one file](pypsa.md) on, are a proof of -concept. They sit in the Development section. +concept. They sit in the Development section, and +[PyPSA in 24 files](pypsa/index.md) is the same spec composed from fragments. [Typeset the math](../reference/typeset.md) prints your own. diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 9b5ecd3f..5fa89599 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5124,18 +5124,60 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | | $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | | $`\mathit{w}^{e}`$ | `Store_closing_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{V}`$ — what the energy a store holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{Generator\_primary\_energy}`$ | `Generator_primary_energy` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{StorageUnit\_primary\_energy}`$ | `StorageUnit_primary_energy` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{Store\_primary\_energy}`$ | `Store_primary_energy` over $`\Xi \times \mathcal{I}`$ | | $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{I}`$ — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{Generator\_operational\_limit}`$ | `Generator_operational_limit` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{StorageUnit\_operational\_limit}`$ | `StorageUnit_operational_limit` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{Store\_operational\_limit}`$ | `Store_operational_limit` over $`\Xi \times \mathcal{I}`$ | | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{I}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{Line\_transmission\_volume\_expansion}`$ | `Line_transmission_volume_expansion` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{Link\_transmission\_volume\_expansion}`$ | `Link_transmission_volume_expansion` over $`\Xi \times \mathcal{I}`$ | | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{I}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{Line\_transmission\_expansion\_cost}`$ | `Line_transmission_expansion_cost` over $`\Xi \times \mathcal{I}`$ | +| $`\mathit{Link\_transmission\_expansion\_cost}`$ | `Link_transmission_expansion_cost` over $`\Xi \times \mathcal{I}`$ | | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{I}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | +| $`\mathit{Generator\_tech\_capacity\_expansion}`$ | `Generator_tech_capacity_expansion` over $`\mathcal{I}`$ | +| $`\mathit{Line\_tech\_capacity\_expansion}`$ | `Line_tech_capacity_expansion` over $`\mathcal{I}`$ | +| $`\mathit{Link\_tech\_capacity\_expansion}`$ | `Link_tech_capacity_expansion` over $`\mathcal{I}`$ | +| $`\mathit{Process\_tech\_capacity\_expansion}`$ | `Process_tech_capacity_expansion` over $`\mathcal{I}`$ | +| $`\mathit{StorageUnit\_tech\_capacity\_expansion}`$ | `StorageUnit_tech_capacity_expansion` over $`\mathcal{I}`$ | +| $`\mathit{Store\_tech\_capacity\_expansion}`$ | `Store_tech_capacity_expansion` over $`\mathcal{I}`$ | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{I}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{Generator\_opex}`$ | `Generator_opex` over $`\Xi`$ | +| $`\mathit{Generator\_commitment\_opex}`$ | `Generator_commitment_opex` over $`\Xi`$ | +| $`\mathit{Link\_opex}`$ | `Link_opex` over $`\Xi`$ | +| $`\mathit{Link\_commitment\_opex}`$ | `Link_commitment_opex` over $`\Xi`$ | +| $`\mathit{Process\_opex}`$ | `Process_opex` over $`\Xi`$ | +| $`\mathit{Process\_commitment\_opex}`$ | `Process_commitment_opex` over $`\Xi`$ | +| $`\mathit{StorageUnit\_opex}`$ | `StorageUnit_opex` over $`\Xi`$ | +| $`\mathit{Store\_opex}`$ | `Store_opex` over $`\Xi`$ | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{Generator\_additions}`$ | `Generator_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Line\_additions}`$ | `Line_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Link\_additions}`$ | `Link_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Process\_additions}`$ | `Process_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{StorageUnit\_additions}`$ | `StorageUnit_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Store\_additions}`$ | `Store_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | | $`\mathrm{r}^{+}`$ | `Carrier_relative_growth` over $`\mathcal{I}`$ — the share of the previous period's additions a carrier's growth limit reads — PyPSA's `max_relative_growth` clipped at zero, so a negative share adds nothing and never tightens the limit | | $`\check{\mathrm{load}}`$ | `Load_demand` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — what a load draws from its bus's balance — its demand times its sign where it is active, nothing where it is not, since PyPSA drops an inactive load from the balance (`constraints.py:1537-1538`) | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | +| $`\mathit{Generator\_injection}`$ | `Generator_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Line\_injection}`$ | `Line_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Link\_injection}`$ | `Link_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathrm{Load\_injection}`$ | `Load_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Process\_injection}`$ | `Process_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{StorageUnit\_injection}`$ | `StorageUnit_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Store\_injection}`$ | `Store_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Transformer\_injection}`$ | `Transformer_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | +| $`\mathit{Line\_angle\_sum}`$ | `Line_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | +| $`\mathit{Transformer\_angle\_sum}`$ | `Transformer_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | Upright is what the data supplies — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -8256,11 +8298,7 @@ Kirchhoff_Voltage_Law: constant where the shift is fixed, or the shift decision times its cycle weight where the shift is a phase-shifting transformer's to choose dims: [scenario, snapshot, cycle] - expression: >- - sum(Line_s * Line_cycle_weight, over=line) - + sum(Transformer_s * Transformer_cycle_weight, over=transformer) - + sum(Transformer_phase_shift_weight, over=transformer) - + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) == 0 + expression: Cycle_angle_sum == 0 ``` ```math @@ -9126,26 +9164,11 @@ Bus_nodal_balance: an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] - expression: >- - sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) - + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) - + sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) - + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - - sum(Line_s, by=Line_bus0, over=line, into=bus) - + sum(Line_s, by=Line_bus1, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) - + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == -sum(Load_demand, by=Load_bus, over=load, into=bus) + expression: Bus_injection == 0 ``` ```math -\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = -\left( \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \check{\mathrm{load}}_{\xi,t,d} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +\sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} - \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} + \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \check{\mathrm{load}}_{\xi,t,d} + \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} - \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` ### `Carrier-growth_limit` @@ -9961,144 +9984,497 @@ Store_closing_weight: \mathit{w}^{e}_{\xi,i,t,v} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}^{e}_{\xi,v} \wedge \mathrm{in}_{\xi,i,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{\xi,i,t} & \text{if } \neg \mathrm{reset}^{e}_{\xi,v} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I},\ t \in \mathcal{T},\ v \in \mathcal{V} ``` +### `Generator_primary_energy` + +```yaml +Generator_primary_energy: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) +``` + +```math +\mathit{Generator\_primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{a}_{\xi,i,g} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `StorageUnit_primary_energy` + +```yaml +StorageUnit_primary_energy: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) +``` + +```math +\mathit{StorageUnit\_primary\_energy}_{\xi,i} = -\left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{a}^{h}_{\xi,i,s} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Store_primary_energy` + +```yaml +Store_primary_energy: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) +``` + +```math +\mathit{Store\_primary\_energy}_{\xi,i} = -\left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{a}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + ### `primary_energy` ```yaml primary_energy: + expression: Generator_primary_energy + StorageUnit_primary_energy + Store_primary_energy description: >- what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant +``` + +```math +\mathit{primary\_energy}_{\xi,i} = \mathit{Generator\_primary\_energy}_{\xi,i} + \mathit{StorageUnit\_primary\_energy}_{\xi,i} + \mathit{Store\_primary\_energy}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Generator_operational_limit` + +```yaml +Generator_operational_limit: expression: >- - sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_primary_energy_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * Store_closing_weight * Store_primary_energy_weight, over=snapshot), over=store) + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) ``` ```math -\mathit{primary\_energy}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{a}_{\xi,i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{a}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{a}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{Generator\_operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{b}_{\xi,i,g} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `StorageUnit_operational_limit` + +```yaml +StorageUnit_operational_limit: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) +``` + +```math +\mathit{StorageUnit\_operational\_limit}_{\xi,i} = -\left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{b}^{h}_{\xi,i,s} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Store_operational_limit` + +```yaml +Store_operational_limit: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) +``` + +```math +\mathit{Store\_operational\_limit}_{\xi,i} = -\left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{b}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `operational_limit` ```yaml operational_limit: + expression: >- + Generator_operational_limit + + StorageUnit_operational_limit + + Store_operational_limit description: >- what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant - expression: >- - sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_operational_limit_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * Store_closing_weight * Store_operational_limit_weight, over=snapshot), over=store) ``` ```math -\mathit{operational\_limit}_{\xi,i} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,i,t} \cdot \mathrm{b}_{\xi,i,g} - \left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,i,t,s} \cdot \mathrm{b}^{h}_{\xi,i,s} \right) - \left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,i,t,v} \cdot \mathrm{b}^{e}_{\xi,i,v} \right) \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{operational\_limit}_{\xi,i} = \mathit{Generator\_operational\_limit}_{\xi,i} + \mathit{StorageUnit\_operational\_limit}_{\xi,i} + \mathit{Store\_operational\_limit}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Line_transmission_volume_expansion` + +```yaml +Line_transmission_volume_expansion: + expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) +``` + +```math +\mathit{Line\_transmission\_volume\_expansion}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{\xi,i,k} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Link_transmission_volume_expansion` + +```yaml +Link_transmission_volume_expansion: + expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) +``` + +```math +\mathit{Link\_transmission\_volume\_expansion}_{\xi,i} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_volume_expansion` ```yaml transmission_volume_expansion: - description: what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches - expression: >- - sum(Line_s_nom_ext * Line_volume_weight, over=line) - + sum(Link_p_nom_ext * Link_volume_weight, over=link) + expression: Line_transmission_volume_expansion + Link_transmission_volume_expansion + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches ``` ```math -\mathit{transmission\_volume\_expansion}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{\xi,i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{transmission\_volume\_expansion}_{\xi,i} = \mathit{Line\_transmission\_volume\_expansion}_{\xi,i} + \mathit{Link\_transmission\_volume\_expansion}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Line_transmission_expansion_cost` + +```yaml +Line_transmission_expansion_cost: + expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) +``` + +```math +\mathit{Line\_transmission\_expansion\_cost}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{\xi,i,k} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Link_transmission_expansion_cost` + +```yaml +Link_transmission_expansion_cost: + expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) +``` + +```math +\mathit{Link\_transmission\_expansion\_cost}_{\xi,i} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} ``` ### `transmission_expansion_cost` ```yaml transmission_expansion_cost: - description: what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches - expression: >- - sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) - + sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + expression: Line_transmission_expansion_cost + Link_transmission_expansion_cost + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches ``` ```math -\mathit{transmission\_expansion\_cost}_{\xi,i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{\xi,i,k} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{\xi,i,l} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +\mathit{transmission\_expansion\_cost}_{\xi,i} = \mathit{Line\_transmission\_expansion\_cost}_{\xi,i} + \mathit{Link\_transmission\_expansion\_cost}_{\xi,i} \qquad \forall\, \xi \in \Xi,\ i \in \mathcal{I} +``` + +### `Generator_tech_capacity_expansion` + +```yaml +Generator_tech_capacity_expansion: + expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) +``` + +```math +\mathit{Generator\_tech\_capacity\_expansion}_{i} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{i,g} \qquad \forall\, i \in \mathcal{I} +``` + +### `Line_tech_capacity_expansion` + +```yaml +Line_tech_capacity_expansion: + expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) +``` + +```math +\mathit{Line\_tech\_capacity\_expansion}_{i} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{i,k} \qquad \forall\, i \in \mathcal{I} +``` + +### `Link_tech_capacity_expansion` + +```yaml +Link_tech_capacity_expansion: + expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) +``` + +```math +\mathit{Link\_tech\_capacity\_expansion}_{i} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{i,l} \qquad \forall\, i \in \mathcal{I} +``` + +### `Process_tech_capacity_expansion` + +```yaml +Process_tech_capacity_expansion: + expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) +``` + +```math +\mathit{Process\_tech\_capacity\_expansion}_{i} = \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{i,j} \qquad \forall\, i \in \mathcal{I} +``` + +### `StorageUnit_tech_capacity_expansion` + +```yaml +StorageUnit_tech_capacity_expansion: + expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) +``` + +```math +\mathit{StorageUnit\_tech\_capacity\_expansion}_{i} = \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{i,s} \qquad \forall\, i \in \mathcal{I} +``` + +### `Store_tech_capacity_expansion` + +```yaml +Store_tech_capacity_expansion: + expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) +``` + +```math +\mathit{Store\_tech\_capacity\_expansion}_{i} = \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{i,v} \qquad \forall\, i \in \mathcal{I} ``` ### `tech_capacity_expansion` ```yaml tech_capacity_expansion: - description: what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set expression: >- - sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) - + sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) - + sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) - + sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) - + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) - + sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + Generator_tech_capacity_expansion + + Line_tech_capacity_expansion + + Link_tech_capacity_expansion + + Process_tech_capacity_expansion + + StorageUnit_tech_capacity_expansion + + Store_tech_capacity_expansion + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set ``` ```math -\mathit{tech\_capacity\_expansion}_{i} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{i,g} + \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{i,l} + \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{i,k} + \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{i,s} + \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{i,v} + \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{i,j} \qquad \forall\, i \in \mathcal{I} +\mathit{tech\_capacity\_expansion}_{i} = \mathit{Generator\_tech\_capacity\_expansion}_{i} + \mathit{Line\_tech\_capacity\_expansion}_{i} + \mathit{Link\_tech\_capacity\_expansion}_{i} + \mathit{Process\_tech\_capacity\_expansion}_{i} + \mathit{StorageUnit\_tech\_capacity\_expansion}_{i} + \mathit{Store\_tech\_capacity\_expansion}_{i} \qquad \forall\, i \in \mathcal{I} ``` -### `scenario_opex` +### `Generator_opex` ```yaml -scenario_opex: - description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) +Generator_opex: expression: >- - sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_p * Process_p * Process_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_p_dispatch * StorageUnit_p_dispatch * StorageUnit_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) +``` + +```math +\mathit{Generator\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +### `Generator_commitment_opex` + +```yaml +Generator_commitment_opex: + expression: >- + sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) - + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) +``` + +```math +\mathit{Generator\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} \qquad \forall\, \xi \in \Xi +``` + +### `Link_opex` + +```yaml +Link_opex: + expression: >- + sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) +``` + +```math +\mathit{Link\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +### `Link_commitment_opex` + +```yaml +Link_commitment_opex: + expression: >- + sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) - + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) +``` + +```math +\mathit{Link\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} \qquad \forall\, \xi \in \Xi +``` + +### `Process_opex` + +```yaml +Process_opex: + expression: >- + sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) +``` + +```math +\mathit{Process\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +### `Process_commitment_opex` + +```yaml +Process_commitment_opex: + expression: >- + sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) ``` ```math -\mathit{scenario\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \qquad \forall\, \xi \in \Xi +\mathit{Process\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \qquad \forall\, \xi \in \Xi +``` + +### `StorageUnit_opex` + +```yaml +StorageUnit_opex: + expression: >- + sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) +``` + +```math +\mathit{StorageUnit\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +### `Store_opex` + +```yaml +Store_opex: + expression: >- + sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) +``` + +```math +\mathit{Store\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +### `scenario_opex` + +```yaml +scenario_opex: + expression: >- + Generator_opex + + Generator_commitment_opex + + Link_opex + + Link_commitment_opex + + Process_opex + + Process_commitment_opex + + StorageUnit_opex + + Store_opex + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) +``` + +```math +\mathit{scenario\_opex}_{\xi} = \mathit{Generator\_opex}_{\xi} + \mathit{Generator\_commitment\_opex}_{\xi} + \mathit{Link\_opex}_{\xi} + \mathit{Link\_commitment\_opex}_{\xi} + \mathit{Process\_opex}_{\xi} + \mathit{Process\_commitment\_opex}_{\xi} + \mathit{StorageUnit\_opex}_{\xi} + \mathit{Store\_opex}_{\xi} \qquad \forall\, \xi \in \Xi +``` + +### `Generator_additions` + +```yaml +Generator_additions: + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) +``` + +```math +\mathit{Generator\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +### `Line_additions` + +```yaml +Line_additions: + expression: >- + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) +``` + +```math +\mathit{Line\_additions}_{y,i} = \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +### `Link_additions` + +```yaml +Link_additions: + expression: >- + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) +``` + +```math +\mathit{Link\_additions}_{y,i} = \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +### `Process_additions` + +```yaml +Process_additions: + expression: >- + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) +``` + +```math +\mathit{Process\_additions}_{y,i} = \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +### `StorageUnit_additions` + +```yaml +StorageUnit_additions: + expression: >- + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) +``` + +```math +\mathit{StorageUnit\_additions}_{y,i} = \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +### `Store_additions` + +```yaml +Store_additions: + expression: >- + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) +``` + +```math +\mathit{Store\_additions}_{y,i} = \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} ``` ### `Carrier_additions` ```yaml Carrier_additions: + expression: >- + Generator_additions + + Line_additions + + Link_additions + + Process_additions + + StorageUnit_additions + + Store_additions description: >- what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like PyPSA, - it sums only the components that carry a carrier attribute, so a + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier - expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) - + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) - + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) - + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) - + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) ``` ```math -\mathit{Carrier\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} + \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} + \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} + \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} + \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +\mathit{Carrier\_additions}_{y,i} = \mathit{Generator\_additions}_{y,i} + \mathit{Line\_additions}_{y,i} + \mathit{Link\_additions}_{y,i} + \mathit{Process\_additions}_{y,i} + \mathit{StorageUnit\_additions}_{y,i} + \mathit{Store\_additions}_{y,i} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} ``` ### `Carrier_relative_growth` @@ -10187,6 +10563,165 @@ Outage_s: \hat{s}_{\xi,t,\kappa} = \begin{cases} \check{s}_{\xi,t,\mathrm{Outage\_line}(\kappa)} & \text{if } \mathrm{Outage\_line}(\kappa) \text{ is defined} \\ \check{\sigma}_{\xi,t,\mathrm{Outage\_transformer}(\kappa)} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ \kappa \in \mathcal{K}^{\mathrm{out}} ``` +### `Generator_injection` + +```yaml +Generator_injection: + expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) +``` + +```math +\mathit{Generator\_injection}_{\xi,t,n} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Line_injection` + +```yaml +Line_injection: + expression: >- + -sum(Line_s, by=Line_bus0, over=line, into=bus) + + sum(Line_s, by=Line_bus1, over=line, into=bus) + - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) + - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) +``` + +```math +\mathit{Line\_injection}_{\xi,t,n} = -\left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Link_injection` + +```yaml +Link_injection: + expression: >- + -sum(Link_p, by=Link_bus0, over=link, into=bus) + + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) +``` + +```math +\mathit{Link\_injection}_{\xi,t,n} = -\left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Load_injection` + +```yaml +Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) +``` + +```math +\mathrm{Load\_injection}_{\xi,t,n} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \check{\mathrm{load}}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Process_injection` + +```yaml +Process_injection: + expression: >- + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) +``` + +```math +\mathit{Process\_injection}_{\xi,t,n} = \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `StorageUnit_injection` + +```yaml +StorageUnit_injection: + expression: >- + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) +``` + +```math +\mathit{StorageUnit\_injection}_{\xi,t,n} = \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Store_injection` + +```yaml +Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) +``` + +```math +\mathit{Store\_injection}_{\xi,t,n} = \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Transformer_injection` + +```yaml +Transformer_injection: + expression: >- + -sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) + - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) +``` + +```math +\mathit{Transformer\_injection}_{\xi,t,n} = -\left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Bus_injection` + +```yaml +Bus_injection: + expression: >- + Generator_injection + + Line_injection + + Link_injection + + Load_injection + + Process_injection + + StorageUnit_injection + + Store_injection + + Transformer_injection + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side +``` + +```math +\mathit{Bus\_injection}_{\xi,t,n} = \mathit{Generator\_injection}_{\xi,t,n} + \mathit{Line\_injection}_{\xi,t,n} + \mathit{Link\_injection}_{\xi,t,n} + \mathrm{Load\_injection}_{\xi,t,n} + \mathit{Process\_injection}_{\xi,t,n} + \mathit{StorageUnit\_injection}_{\xi,t,n} + \mathit{Store\_injection}_{\xi,t,n} + \mathit{Transformer\_injection}_{\xi,t,n} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +### `Line_angle_sum` + +```yaml +Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) +``` + +```math +\mathit{Line\_angle\_sum}_{\xi,t,c} = \sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + +### `Transformer_angle_sum` + +```yaml +Transformer_angle_sum: + expression: >- + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) +``` + +```math +\mathit{Transformer\_angle\_sum}_{\xi,t,c} = \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} + \sum_{m \in \mathcal{M}} \mathit{Transformer\_phase\_shift}_{\xi,t,m} \cdot \mathrm{Transformer\_phase\_shift\_cycle\_weight}_{m,c} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + +### `Cycle_angle_sum` + +```yaml +Cycle_angle_sum: + expression: Line_angle_sum + Transformer_angle_sum + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift +``` + +```math +\mathit{Cycle\_angle\_sum}_{\xi,t,c} = \mathit{Line\_angle\_sum}_{\xi,t,c} + \mathit{Transformer\_angle\_sum}_{\xi,t,c} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + #### Variable domains **`Generator_p`** diff --git a/docs/examples/pypsa/carrier.md b/docs/examples/pypsa/carrier.md new file mode 100644 index 00000000..aaa4e562 --- /dev/null +++ b/docs/examples/pypsa/carrier.md @@ -0,0 +1,107 @@ + + +# Carriers + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the growth limits per carrier, which read `Carrier_additions`. It reads `Carrier_additions` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +parameters: + Carrier_max_growth: + description: >- + most capacity of a carrier that may be added in a period; no value means + no limit. The least over the scenarios, as PyPSA takes it + (`global_constraints.py:226-230`), data prep. PyPSA reads it only under + `multi_investment_periods` (`global_constraints.py:219-220`), so data + prep feeds no value otherwise + dims: [carrier] + Carrier_max_relative_growth: + description: >- + share of the previous period's additions that may be added on top — the + least over the scenarios, as PyPSA takes it, data prep + dims: [carrier] + +given: + expressions: + Carrier_additions: { dims: [period, carrier] } + +expressions: + Carrier_relative_growth: + description: >- + the share of the previous period's additions a carrier's growth limit + reads — PyPSA's `max_relative_growth` clipped at zero, so a negative + share adds nothing and never tightens the limit + dims: [carrier] + cases: + positive: { when: Carrier_max_relative_growth > 0, expression: Carrier_max_relative_growth } + otherwise: 0 + +constraints: + Carrier_growth_limit: + description: >- + `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, + counting each build in the first period it stands in, is at most its allowance plus a share of + what it added the period before; the first period has no predecessor, so `edge=0` leaves it the + bare allowance + dims: [carrier, period] + where: Carrier_max_growth + expression: >- + Carrier_additions + - shift(Carrier_additions, along=period, offset=1, edge=0) * Carrier_relative_growth + <= Carrier_max_growth +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{Y}`$ | index $`y`$ — `period` — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | +| $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression another file defines | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\mathrm{r}^{+}`$ | `Carrier_relative_growth` over $`\mathcal{I}`$ — the share of the previous period's additions a carrier's growth limit reads — PyPSA's `max_relative_growth` clipped at zero, so a negative share adds nothing and never tightens the limit | + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +#### Subject to + +**`Carrier_growth_limit`** + +```math +\mathit{Carrier\_additions}_{y,i} - \mathit{Carrier\_additions}_{y \boxminus_{0} 1,i} \cdot \mathrm{r}^{+}_{i} \le \overline{\Delta}_{i} \qquad \forall\, i \in \mathcal{I},\ y \in \mathcal{Y} \,:\, \overline{\Delta}_{i} \text{ is defined} +``` + +#### Definitions + +**`Carrier_relative_growth`** + +```math +\mathrm{r}^{+}_{i} = \begin{cases} \mathrm{r}_{i} & \text{if } \mathrm{r}_{i} > 0 \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, i \in \mathcal{I} +``` + diff --git a/docs/examples/pypsa/cost.md b/docs/examples/pypsa/cost.md new file mode 100644 index 00000000..e2e95f6b --- /dev/null +++ b/docs/examples/pypsa/cost.md @@ -0,0 +1,129 @@ + + +# The cost + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the objective: the expected operating cost and its tail, weighted by scenario. It reads `CVaR_omega`, `scenario_opex`, `scenario_weight` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + +parameters: + CVaR_inv_tail: + description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + dims: [] + +variables: + CVaR_a: + description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" + dims: [scenario] + bounds: + lower: 0 + CVaR_theta: + description: "`CVaR-theta` — where the tail starts, the value at risk" + dims: [] + CVaR: + description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" + dims: [] + +given: + parameters: + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + expressions: + scenario_opex: { dims: [scenario] } + +constraints: + CVaR_excess: + description: "`CVaR-excess-{s}` — a scenario's operating cost beyond the tail's start is its excess; PyPSA names one row per scenario" + dims: [scenario] + where: CVaR_omega > 0 + expression: CVaR_a - scenario_opex + CVaR_theta >= 0 + CVaR_def: + description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" + dims: [] + where: CVaR_omega > 0 + expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + +objective: + sense: minimize + description: >- + capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail + expression: >- + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + + CVaR_omega * CVaR +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`a`$ | `CVaR_a` over $`\Xi`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | +| $`\theta`$ | `CVaR_theta` (scalar) — `CVaR-theta` — where the tail starts, the value at risk | +| $`CVaR`$ | `CVaR` (scalar) — `CVaR` — the tail's average cost, what the objective prices at `omega` | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression another file defines | + +#### Objective + +```math +\min \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +``` + +#### Subject to + +**`CVaR_excess`** + +```math +a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in \Xi \,:\, \omega > 0 +``` + +**`CVaR_def`** + +```math +\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 +``` + +#### Variable domains + +**`CVaR_a`** + +```math +a_{\xi} \ge 0 \qquad \forall\, \xi \in \Xi +``` + +**`CVaR_theta`** + +```math +\theta \in \mathbb{R} +``` + +**`CVaR`** + +```math +CVaR \in \mathbb{R} +``` + diff --git a/docs/examples/pypsa/generator.md b/docs/examples/pypsa/generator.md new file mode 100644 index 00000000..0b0b6547 --- /dev/null +++ b/docs/examples/pypsa/generator.md @@ -0,0 +1,516 @@ + + +# Generators + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Generator_committable`, `Generator_maintenance`, `Generator_maintenance_capacity`, `Generator_maintenance_pu`, `GlobalConstraint_energy_weight` and 4 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + generator: + description: generating units, each on one bus + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Generator_carrier: + description: the carrier a generator converts from + key: generator + values: carrier + Generator_bus: + description: the bus a generator sits on + key: generator + values: bus + +parameters: + Generator_p_nom: + description: nominal power + dims: [scenario, generator] + Generator_p_nom_extendable: + description: whether the nominal power is a decision + dims: [generator] + dtype: bool + Generator_p_min_pu: + description: least output, per unit of nominal power + dims: [scenario, snapshot, generator] + Generator_p_max_pu: + description: most output, per unit of nominal power — an availability profile + dims: [scenario, snapshot, generator] + Generator_marginal_cost: + description: cost of one unit of output + dims: [scenario, snapshot, generator] + Generator_marginal_cost_quadratic: + description: cost of the square of one unit of output + dims: [scenario, snapshot, generator] + Generator_sign: + description: >- + the sign output enters its bus's balance with — PyPSA's `sign`, `1` + unless given, `-1` for a unit that draws power. PyPSA refuses one that + differs by scenario (`consistency.py:1187`) + dims: [generator] + Generator_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [generator] + Generator_modules_installed: + description: >- + how many whole modules a committable build has in place: `Generator_p_nom + / Generator_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, generator] + Generator_p_min_pu_nonneg: + description: >- + true where none of the generator's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [generator] + dtype: bool + Generator_active: + description: whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep + dims: [snapshot, generator] + dtype: bool + Generator_capital_weight: + description: the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [generator] + Generator_first_active: + description: >- + one in the first period a generator stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a generator + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, generator] + Generator_p_set: + description: a given output schedule; a generator without one has no row here + dims: [scenario, snapshot, generator] + Generator_p_nom_min: + description: least nominal power an extendable generator may be built at + dims: [scenario, generator] + Generator_p_nom_max: + description: most nominal power an extendable generator may be built at + dims: [scenario, generator] + Generator_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, generator] + Generator_p_nom_set: + description: a given nominal power for an extendable generator; one without a value has no row here + dims: [scenario, generator] + Generator_e_sum_min: + description: least energy over the horizon; minus infinity where no floor is meant + dims: [scenario, generator] + Generator_e_sum_max: + description: most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant + dims: [scenario, generator] + Generator_primary_energy_weight: + description: >- + the constrained attribute per unit of energy at the bus — the carrier's + `co2_emissions` over the generator's efficiency, data prep; a generator + of an unweighted carrier has no row + dims: [scenario, global_constraint, generator] + Generator_operational_limit_weight: + description: one where the generator is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, generator] + Generator_tech_capacity_weight: + description: >- + one where the generator is in the row's carrier-and-bus set — data + prep; one outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [global_constraint, generator] + +variables: + Generator_p: + description: "`Generator-p` — output of a generator in a snapshot" + dims: [scenario, snapshot, generator] + where: Generator_active + Generator_n_mod: + description: "`Generator-n_mod` — how many modules of an extendable modular build" + dims: [generator] + where: Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Generator_p_nom_ext: + description: >- + `Generator-p_nom` — nominal power where it is a decision; the parameter + of the same PyPSA name carries the fixed regime + dims: [generator] + where: Generator_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Generator_committable: { dims: [generator], dtype: bool } + Generator_maintenance_pu: { dims: [scenario, generator] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + snapshot_weightings_generators: { dims: [snapshot] } + variables: + Generator_maintenance: { dims: [scenario, snapshot, generator] } + Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } + expressions: + GlobalConstraint_energy_weight: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: Generator_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: Generator_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: Generator_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Generator_opex } + Carrier_additions: { dims: [period, carrier], term: Generator_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Generator_injection } + +expressions: + Generator_p_nom_effective: + description: the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, generator] + cases: + extendable: { when: Generator_p_nom_extendable, expression: Generator_p_nom_ext } + otherwise: Generator_p_nom + Generator_p_nom_committed: + description: >- + the build a committed unit's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, generator] + cases: + modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } + otherwise: Generator_p_nom + Generator_primary_energy: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + Generator_operational_limit: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + Generator_tech_capacity_expansion: + expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + Generator_opex: + expression: >- + sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + Generator_additions: + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + Generator_injection: + expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + +constraints: + Generator_fix_p_lower: + description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) + Generator_fix_p_upper: + description: "`Generator-fix-p-upper` — a fixed generator outputs at most what is available" + dims: [scenario, snapshot, generator] + where: not Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (1 - Generator_maintenance_pu * Generator_maintenance) + Generator_ext_p_lower: + description: "`Generator-ext-p-lower` — an extendable generator outputs at least its minimum of the chosen build" + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_ext_p_upper: + description: "`Generator-ext-p-upper` — an extendable generator outputs at most what is available of the chosen build" + dims: [scenario, snapshot, generator] + where: Generator_p_nom_extendable AND not Generator_committable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_ext_p_nom_lower: + description: "`Generator-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, generator] + where: Generator_p_nom_extendable + expression: Generator_p_nom_ext >= Generator_p_nom_min + Generator_ext_p_nom_upper: + description: "`Generator-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, generator] + where: Generator_p_nom_extendable AND Generator_p_nom_max + expression: Generator_p_nom_ext <= Generator_p_nom_max + Generator_p_nom_set: + description: "`Generator-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, generator] + where: Generator_p_nom_extendable AND Generator_p_nom_set + expression: Generator_p_nom_ext == Generator_p_nom_set + Generator_e_sum_min: + description: "`Generator-e_sum_min` — energy over the horizon is at least its floor; a floor of minus infinity is no row" + dims: [scenario, generator] + where: Generator_e_sum_min + expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) >= Generator_e_sum_min + Generator_e_sum_max: + description: "`Generator-e_sum_max` — energy over the horizon is at most its budget; a budget of infinity is no row" + dims: [scenario, generator] + where: Generator_e_sum_max + expression: sum(Generator_p * snapshot_weightings_generators, over=snapshot) <= Generator_e_sum_max + Generator_p_nom_modularity: + description: "`Generator-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [generator] + where: Generator_p_nom_extendable AND Generator_p_nom_mod > 0 + expression: Generator_p_nom_ext == Generator_p_nom_mod * Generator_n_mod + Generator_p_set: + description: "`Generator-p_set` — output pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, generator] + where: Generator_p_set AND Generator_active + expression: Generator_p == Generator_p_set + +assumptions: + Generator_marginal_cost_quadratic_without_risk_preference: + holds: "Generator_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Generator_p_nom_ext) * Generator_capital_cost) * Generator_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I},\ \mathrm{Generator\_bus}: \mathcal{G} \to \mathcal{N}`$ — generating units, each on one bus | +| $`\mathcal{L}`$ | index $`l`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Generator\_carrier}: \mathcal{G} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\Xi \times \mathcal{G}`$ — nominal power | +| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$ — whether the nominal power is a decision | +| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — least output, per unit of nominal power | +| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most output, per unit of nominal power — an availability profile | +| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | +| $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | +| $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\Xi \times \mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | +| $`\mathrm{W}`$ | `Generator_capital_weight` over $`\mathcal{G}`$ — the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a generator that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | +| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\Xi \times \mathcal{G}`$ — least nominal power an extendable generator may be built at | +| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\Xi \times \mathcal{G}`$ — most nominal power an extendable generator may be built at | +| $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\Xi \times \mathcal{G}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$ — a given nominal power for an extendable generator; one without a value has no row here | +| $`\underline{\mathrm{E}}`$ | `Generator_e_sum_min` over $`\Xi \times \mathcal{G}`$ — least energy over the horizon; minus infinity where no floor is meant | +| $`\overline{\mathrm{E}}`$ | `Generator_e_sum_max` over $`\Xi \times \mathcal{G}`$ — most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{L} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{L} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{L} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`p`$ | `Generator_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-p` — output of a generator in a snapshot | +| $`N`$ | `Generator_n_mod` over $`\mathcal{G}`$ — `Generator-n_mod` — how many modules of an extendable modular build | +| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$, data another file declares | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$, data another file declares | +| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{L} \times \mathcal{T}`$, an expression another file defines | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{L}`$, an expression this file adds `Generator_primary_energy` to | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{L}`$, an expression this file adds `Generator_operational_limit` to | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{L}`$, an expression this file adds `Generator_tech_capacity_expansion` to | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Generator_opex` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Generator_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Generator_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\Xi \times \mathcal{G}`$ — the build a generator's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\Xi \times \mathcal{G}`$ — the build a committed unit's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\mathit{Generator\_primary\_energy}`$ | `Generator_primary_energy` over $`\Xi \times \mathcal{L}`$ | +| $`\mathit{Generator\_operational\_limit}`$ | `Generator_operational_limit` over $`\Xi \times \mathcal{L}`$ | +| $`\mathit{Generator\_tech\_capacity\_expansion}`$ | `Generator_tech_capacity_expansion` over $`\mathcal{L}`$ | +| $`\mathit{Generator\_opex}`$ | `Generator_opex` over $`\Xi`$ | +| $`\mathit{Generator\_additions}`$ | `Generator_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Generator\_injection}`$ | `Generator_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} +``` + +#### Subject to + +**`Generator_fix_p_lower`** + +```math +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \gamma_{\xi,g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_fix_p_upper`** + +```math +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \gamma_{\xi,g} \cdot \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_ext_p_lower`** + +```math +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_ext_p_upper`** + +```math +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \neg \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_ext_p_nom_lower`** + +```math +P_{g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} +``` + +**`Generator_ext_p_nom_upper`** + +```math +P_{g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \text{ is defined} +``` + +**`Generator_p_nom_set`** + +```math +P_{g} = \mathrm{p}^{\mathrm{nom,set}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{nom,set}}_{\xi,g} \text{ is defined} +``` + +**`Generator_e_sum_min`** + +```math +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \ge \underline{\mathrm{E}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \underline{\mathrm{E}}_{\xi,g} \text{ is defined} +``` + +**`Generator_e_sum_max`** + +```math +\sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathrm{w}^{\mathrm{gen}}_{t} \le \overline{\mathrm{E}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \overline{\mathrm{E}}_{\xi,g} \text{ is defined} +``` + +**`Generator_p_nom_modularity`** + +```math +P_{g} = \mathrm{p}^{\mathrm{mod}}_{g} \cdot N_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +``` + +**`Generator_p_set`** + +```math +p_{\xi,t,g} = \mathrm{p}^{\mathrm{set}}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{set}}_{\xi,t,g} \text{ is defined} \wedge \mathrm{on}_{t,g} +``` + +#### Definitions + +**`Generator_p_nom_effective`** + +```math +\widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} = \begin{cases} P_{g} & \text{if } \mathrm{ext}_{g} \\ \mathrm{p}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Generator_p_nom_committed`** + +```math +\widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} = \begin{cases} \mathrm{p}^{\mathrm{mod}}_{g} & \text{if } \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \\ \mathrm{p}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Generator_primary_energy`** + +```math +\mathit{Generator\_primary\_energy}_{\xi,l} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,l,t} \cdot \mathrm{a}_{\xi,l,g} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Generator_operational_limit`** + +```math +\mathit{Generator\_operational\_limit}_{\xi,l} = \sum_{g \in \mathcal{G}} \sum_{t \in \mathcal{T}} p_{\xi,t,g} \cdot \mathit{w}^{\mathrm{gc}}_{\xi,l,t} \cdot \mathrm{b}_{\xi,l,g} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Generator_tech_capacity_expansion`** + +```math +\mathit{Generator\_tech\_capacity\_expansion}_{l} = \sum_{g \in \mathcal{G}} P_{g} \cdot \mathrm{m}_{l,g} \qquad \forall\, l \in \mathcal{L} +``` + +**`Generator_opex`** + +```math +\mathit{Generator\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +**`Generator_additions`** + +```math +\mathit{Generator\_additions}_{y,i} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_carrier}(g) = i} P_{g} \cdot \mathrm{new}_{y,g} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`Generator_injection`** + +```math +\mathit{Generator\_injection}_{\xi,t,n} = \sum_{g \in \mathcal{G} \,:\, \mathrm{Generator\_bus}(g) = n} \mathrm{sgn}_{g} \cdot p_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Generator_p`** + +```math +p_{\xi,t,g} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{on}_{t,g} +``` + +**`Generator_n_mod`** + +```math +N_{g} \ge 0, N_{g} \in \mathbb{Z} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +``` + +**`Generator_p_nom_ext`** + +```math +P_{g} \in \mathbb{R} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{ext}_{g} +``` + +#### Assumptions + +**`Generator_marginal_cost_quadratic_without_risk_preference`** + +```math +\mathrm{c}^{(2)}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \omega > 0 +``` + diff --git a/docs/examples/pypsa/generator_commitment.md b/docs/examples/pypsa/generator_commitment.md new file mode 100644 index 00000000..d793efc3 --- /dev/null +++ b/docs/examples/pypsa/generator_commitment.md @@ -0,0 +1,491 @@ + + +# Generators, the commitment + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`, the commitment rows. It adds a term to `scenario_opex`. It reads `Generator_active`, `Generator_maintenance_capacity`, `Generator_maintenance_pu`, `Generator_maintenance_status`, `Generator_modules_installed`, `Generator_n_mod` and 10 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Generator_committable: + description: whether output is gated by an on/off status decision + dims: [generator] + dtype: bool + Generator_min_up_time: + description: least snapshots a unit stays on once started + dims: [scenario, generator] + dtype: int + Generator_min_down_time: + description: least snapshots a unit stays off once stopped + dims: [scenario, generator] + dtype: int + Generator_status_initial: + description: one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, generator] + dtype: int + Generator_must_stay_up: + description: >- + true while the up time a unit brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, generator] + dtype: bool + Generator_must_stay_down: + description: >- + true while the down time a unit brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, generator] + dtype: bool + Generator_start_up_cost: + description: cost of one start + dims: [scenario, generator] + Generator_shut_down_cost: + description: cost of one stop + dims: [scenario, generator] + Generator_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, generator] + Generator_big_m: + description: a bound safely above any feasible output — the build cap at full availability, data prep + dims: [scenario, generator] + +variables: + Generator_status: + description: >- + `Generator-status` — how much of a committable unit is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + Generator_start_up: + description: "`Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + Generator_shut_down: + description: "`Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Generator_p_nom: { dims: [scenario, generator] } + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_p_min_pu: { dims: [scenario, snapshot, generator] } + Generator_p_max_pu: { dims: [scenario, snapshot, generator] } + Generator_p_nom_mod: { dims: [generator] } + Generator_modules_installed: { dims: [scenario, generator] } + Generator_p_min_pu_nonneg: { dims: [generator], dtype: bool } + Generator_maintenance_pu: { dims: [scenario, generator] } + period_weight_objective: { dims: [period] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + variables: + Generator_p: { dims: [scenario, snapshot, generator] } + Generator_n_mod: { dims: [generator], domain: integer } + Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } + Generator_maintenance_status: { dims: [scenario, snapshot, generator] } + Generator_p_nom_ext: { dims: [generator] } + expressions: + scenario_opex: { dims: [scenario], term: Generator_commitment_opex } + +expressions: + Generator_previous_status: + description: >- + the commitment state a generator carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial } + otherwise: shift(Generator_status, along=snapshot, offset=1) + Generator_commitment_opex: + expression: >- + sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + +constraints: + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most what is available; off, at most nothing" + dims: [scenario, snapshot, generator] + where: Generator_committable AND not Generator_p_nom_extendable AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_transition_start_up: + description: "`Generator-com-transition-start-up` — turning on is a start, counted against the state the unit carried into the snapshot" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + expression: Generator_start_up >= Generator_status - Generator_previous_status + Generator_com_transition_shut_down: + description: "`Generator-com-transition-shut-down` — turning off is a stop, counted against the state the unit carried into the snapshot" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_active + expression: Generator_shut_down >= Generator_previous_status - Generator_status + Generator_com_up_time: + description: >- + `Generator-com-up-time` — a unit started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_up_time > 0 AND position(snapshot) > 0 AND Generator_active + expression: sum_back(Generator_start_up, along=snapshot, window=Generator_min_up_time) <= Generator_status + Generator_com_down_time: + description: >- + `Generator-com-down-time` — a unit stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_min_down_time > 0 AND position(snapshot) > 0 AND Generator_active + expression: sum_back(Generator_shut_down, along=snapshot, window=Generator_min_down_time) <= 1 - Generator_status + Generator_com_status_must_stay_up: + description: "`Generator-com-status-min_up_time_must_stay_up` — a unit still serving the up time it brought in stays on" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_up AND Generator_active + expression: Generator_status == 1 + Generator_com_status_must_stay_down: + description: >- + `Generator-com-status-min_down_time_must_stay_up` — a unit still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_must_stay_down AND Generator_active + expression: Generator_status == 0 + Generator_com_ext_p_upper_cap: + description: >- + `Generator-com-ext-p-upper-cap` — a committed extendable unit outputs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p <= Generator_p_max_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + Generator_com_ext_p_upper_big_m: + description: "`Generator-com-ext-p-upper-bigM` — off, a unit outputs nothing; on, the big M is no bound" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p <= Generator_big_m * Generator_status + Generator_com_ext_p_lower: + description: >- + `Generator-com-ext-p-lower` — a committed extendable unit outputs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: >- + Generator_p >= + Generator_p_min_pu * (Generator_p_nom_ext - Generator_maintenance_pu * Generator_maintenance_capacity) + + Generator_big_m * Generator_status - Generator_big_m + Generator_com_ext_p_lower_nonneg: + description: >- + `Generator-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, output is also plainly non-negative, a row the big-M lower + cannot assert while the unit is off + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable + AND Generator_p_min_pu_nonneg AND NOT (Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_p >= 0 + Generator_com_mod_p_lower: + description: >- + `Generator-com-mod-p-lower` — a committed modular unit outputs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_com_mod_p_upper: + description: >- + `Generator-com-mod-p-upper` — a committed modular unit outputs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_p <= Generator_p_max_pu * Generator_p_nom_mod * (Generator_status - Generator_maintenance_pu * Generator_maintenance_status) + Generator_status_p_fixed_upper: + description: >- + `Generator-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_status <= Generator_modules_installed + Generator_start_up_p_fixed_upper: + description: >- + `Generator-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_start_up <= Generator_modules_installed + Generator_shut_down_p_fixed_upper: + description: >- + `Generator-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, generator] + where: Generator_committable AND NOT (Generator_p_nom_extendable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_shut_down <= Generator_modules_installed + Generator_status_p_nom_variable_upper: + description: "`Generator-status-p_nom-variable-upper` — a modular unit is on only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_status <= Generator_n_mod + Generator_start_up_p_nom_variable_upper: + description: "`Generator-start_up-p_nom-variable-upper` — a modular unit starts only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_start_up <= Generator_n_mod + Generator_shut_down_p_nom_variable_upper: + description: "`Generator-shut_down-p_nom-variable-upper` — a modular unit stops only where a module is built" + dims: [scenario, snapshot, generator] + where: Generator_committable AND Generator_p_nom_extendable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_shut_down <= Generator_n_mod +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units, each on one bus | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | +| $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | +| $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays off once stopped | +| $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\Xi \times \mathcal{G}`$ — cost of one start | +| $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\Xi \times \mathcal{G}`$ — cost of one stop | +| $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | +| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$ — a bound safely above any feasible output — the build cap at full availability, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | +| $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$, data another file declares | +| $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, data another file declares | +| $`\overline{\mathrm{p}}`$ | `Generator_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, data another file declares | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$, data another file declares | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$, data another file declares | +| $`p`$ | `Generator_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`N`$ | `Generator_n_mod` over $`\mathcal{G}`$ | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Generator_commitment_opex` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\mathit{Generator\_commitment\_opex}`$ | `Generator_commitment_opex` over $`\Xi`$ | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +#### Subject to + +**`Generator_com_p_lower`** + +```math +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_p_upper`** + +```math +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_transition_start_up`** + +```math +\mathit{up}_{\xi,t,g} \ge u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_transition_shut_down`** + +```math +\mathit{dn}_{\xi,t,g} \ge \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_up_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}} \mathit{up}_{\xi,t',g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{UT}_{\xi,g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_down_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}} \mathit{dn}_{\xi,t',g} \le 1 - u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{DT}_{\xi,g} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_status_must_stay_up`** + +```math +u_{\xi,t,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{hold}_{\xi,t,g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_status_must_stay_down`** + +```math +u_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{rest}_{\xi,t,g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_ext_p_upper_cap`** + +```math +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_ext_p_upper_big_m`** + +```math +p_{\xi,t,g} \le \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_ext_p_lower`** + +```math +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \left( P_{g} - \gamma_{\xi,g} \cdot \mu^{\mathrm{nom}}_{\xi,t,g} \right) + \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} - \mathrm{M}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_ext_p_lower_nonneg`** + +```math +p_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{nonneg}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_mod_p_lower`** + +```math +p_{\xi,t,g} \ge \underline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_com_mod_p_upper`** + +```math +p_{\xi,t,g} \le \overline{\mathrm{p}}_{\xi,t,g} \cdot \mathrm{p}^{\mathrm{mod}}_{g} \cdot \left( u_{\xi,t,g} - \gamma_{\xi,g} \cdot \mu^{u}_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_status_p_fixed_upper`** + +```math +u_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_start_up_p_fixed_upper`** + +```math +\mathit{up}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_shut_down_p_fixed_upper`** + +```math +\mathit{dn}_{\xi,t,g} \le \mathrm{N}^{\mathrm{fix}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_status_p_nom_variable_upper`** + +```math +u_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_start_up_p_nom_variable_upper`** + +```math +\mathit{up}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_shut_down_p_nom_variable_upper`** + +```math +\mathit{dn}_{\xi,t,g} \le N_{g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +#### Definitions + +**`Generator_previous_status`** + +```math +\overleftarrow{u}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{\xi,g} & \text{if } \mathrm{pos}(t) = 0 \\ u_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`Generator_commitment_opex`** + +```math +\mathit{Generator\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} u_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{on}}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{up}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{up}}_{\xi,g} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{dn}_{\xi,t,g} \cdot \mathrm{c}^{\mathrm{dn}}_{\xi,g} \qquad \forall\, \xi \in \Xi +``` + +#### Variable domains + +**`Generator_status`** + +```math +u_{\xi,t,g} \ge 0, u_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_start_up`** + +```math +\mathit{up}_{\xi,t,g} \ge 0, \mathit{up}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_shut_down`** + +```math +\mathit{dn}_{\xi,t,g} \ge 0, \mathit{dn}_{\xi,t,g} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{on}_{t,g} +``` + diff --git a/docs/examples/pypsa/generator_maintenance.md b/docs/examples/pypsa/generator_maintenance.md new file mode 100644 index 00000000..de416877 --- /dev/null +++ b/docs/examples/pypsa/generator_maintenance.md @@ -0,0 +1,418 @@ + + +# Generators, the maintenance + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`, the maintenance rows. It reads `Generator_active`, `Generator_committable`, `Generator_p_nom_ext`, `Generator_p_nom_extendable`, `Generator_p_nom_max`, `Generator_p_nom_min` and 3 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + +relations: + Generator_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a generator that is + not maintainable + key: { scenario: scenario, generator: generator, start: snapshot, covered: snapshot } + +parameters: + Generator_maintainable: + description: >- + whether a generator must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [generator] + dtype: bool + Generator_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, generator] + Generator_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, generator] + dtype: int + Generator_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the generator is not + maintainable. No row reads it: data prep turns it into + `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, generator] + Generator_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the generator does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, generator] + dtype: bool + +variables: + Generator_maintenance: + description: >- + `Generator-maintenance` — whether a maintainable generator is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + bounds: + lower: 0 + upper: 1 + Generator_maintenance_start: + description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + absence: zero + domain: binary + Generator_maintenance_capacity: + description: >- + `Generator-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_p_nom_extendable + AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + absence: zero + bounds: + lower: 0 + Generator_maintenance_status: + description: >- + `Generator-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, generator] + where: >- + Generator_maintainable AND Generator_committable + AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_committable: { dims: [generator], dtype: bool } + Generator_p_nom_mod: { dims: [generator] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + Generator_p_nom_min: { dims: [scenario, generator] } + Generator_p_nom_max: { dims: [scenario, generator] } + variables: + Generator_status: { dims: [scenario, snapshot, generator], domain: integer } + Generator_p_nom_ext: { dims: [generator] } + +constraints: + Generator_maint_event_count: + description: "`Generator-maint-event-count` — a maintainable generator holds its number of maintenance events over the horizon" + dims: [scenario, generator] + where: Generator_maintainable + expression: sum(Generator_maintenance_start, over=snapshot) == Generator_maintenance_events + Generator_maint_window: + description: >- + `Generator-maint-window` — a generator is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active + expression: Generator_maintenance == sum(Generator_maintenance_start, by=Generator_maintenance_cover, over=start, into=covered) + Generator_maint_start_horizon: + description: "`Generator-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_active AND Generator_maintenance_start_blocked + expression: Generator_maintenance_start == 0 + Generator_maintcap_upper: + description: >- + `Generator-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_ext - Generator_p_nom_min * (1 - Generator_maintenance) + Generator_maintcap_upper_nommax: + description: "`Generator-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity <= Generator_p_nom_max * Generator_maintenance + Generator_maintcap_lower_nommax: + description: "`Generator-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active + expression: Generator_maintenance_capacity >= Generator_p_nom_ext - Generator_p_nom_max * (1 - Generator_maintenance) + Generator_maintcap_lower_nommin: + description: "`Generator-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active AND Generator_p_nom_min > 0 + expression: Generator_maintenance_capacity >= Generator_p_nom_min * Generator_maintenance + Generator_maint_status_le_status: + description: "`Generator-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_status_le_maint: + description: "`Generator-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status <= Generator_maintenance + Generator_maint_status_lb: + description: "`Generator-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_active + expression: Generator_maintenance_status >= Generator_status + Generator_maintenance - 1 + Generator_maint_modstatus_le_status: + description: "`Generator-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_status + Generator_maint_modstatus_le_maint: + description: >- + `Generator-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status <= Generator_p_nom_max / Generator_p_nom_mod * Generator_maintenance + Generator_maint_modstatus_lb: + description: "`Generator-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, generator] + where: Generator_maintainable AND Generator_committable AND Generator_p_nom_mod > 0 AND Generator_active + expression: Generator_maintenance_status >= Generator_status - Generator_p_nom_max / Generator_p_nom_mod * (1 - Generator_maintenance) + +assumptions: + Generator_maintenance_events_positive: + holds: "Generator_maintenance_events > 0" + where: "Generator_maintainable" + description: >- + a maintainable generator with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Generator_maintenance_duration_positive: + holds: "Generator_maintenance_duration > 0" + where: "Generator_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Generator_maintenance_duration_fits_the_horizon: + holds: "Generator_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Generator_maintenance_events_fit_the_horizon: + holds: "Generator_maintenance_duration * Generator_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Generator_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Generator_maintenance_build_cap_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Generator_maintenance_module_count_is_finite: + holds: "Generator_p_nom_max < inf" + where: "Generator_maintainable AND Generator_committable AND NOT Generator_p_nom_extendable AND Generator_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` with $`\mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_maintenance\_cover} \subseteq \Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{T}`$ — generating units, each on one bus | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\Xi \times \mathcal{G}`$ — how many maintenance events the horizon holds | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$, data another file declares | +| $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ | + +#### Subject to + +**`Generator_maint_event_count`** + +```math +\sum_{t \in \mathcal{T}} \mu^{\mathrm{up}}_{\xi,t,g} = \mathrm{n}^{\mathrm{mnt}}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maint_window`** + +```math +\mu_{\xi,t,g} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ g,\ t',\ t \right) \in \mathrm{Generator\_maintenance\_cover}} \mu^{\mathrm{up}}_{\xi,t',g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_start_horizon`** + +```math +\mu^{\mathrm{up}}_{\xi,t,g} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} \wedge \mathrm{blk}_{\xi,t,g} +``` + +**`Generator_maintcap_upper`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \le P_{g} - \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintcap_upper_nommax`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \le \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintcap_lower_nommax`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge P_{g} - \overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintcap_lower_nommin`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} \wedge \underline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} > 0 +``` + +**`Generator_maint_status_le_status`** + +```math +\mu^{u}_{\xi,t,g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_status_le_maint`** + +```math +\mu^{u}_{\xi,t,g} \le \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_status_lb`** + +```math +\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} + \mu_{\xi,t,g} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_modstatus_le_status`** + +```math +\mu^{u}_{\xi,t,g} \le u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_modstatus_le_maint`** + +```math +\mu^{u}_{\xi,t,g} \le \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \mu_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maint_modstatus_lb`** + +```math +\mu^{u}_{\xi,t,g} \ge u_{\xi,t,g} - \frac{\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g}}{\mathrm{p}^{\mathrm{mod}}_{g}} \cdot \left( 1 - \mu_{\xi,t,g} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \wedge \mathrm{on}_{t,g} +``` + +#### Variable domains + +**`Generator_maintenance`** + +```math +0 \le \mu_{\xi,t,g} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_start`** + +```math +\mu^{\mathrm{up}}_{\xi,t,g} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_capacity`** + +```math +\mu^{\mathrm{nom}}_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_maintenance_status`** + +```math +\mu^{u}_{\xi,t,g} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \left( \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \mathrm{on}_{t,g} +``` + +#### Assumptions + +**`Generator_maintenance_events_positive`** + +```math +\mathrm{n}^{\mathrm{mnt}}_{\xi,g} > 0 \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_duration_positive`** + +```math +\tau^{\mathrm{mnt}}_{\xi,g} > 0 \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_duration_fits_the_horizon`** + +```math +\tau^{\mathrm{mnt}}_{\xi,g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_events_fit_the_horizon`** + +```math +\tau^{\mathrm{mnt}}_{\xi,g} \cdot \mathrm{n}^{\mathrm{mnt}}_{\xi,g} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} +``` + +**`Generator_maintenance_build_cap_is_finite`** + +```math +\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} < \infty \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{ext}_{g} +``` + +**`Generator_maintenance_module_count_is_finite`** + +```math +\overline{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} < \infty \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{mnt}_{g} \wedge \mathrm{com}_{g} \wedge \neg \mathrm{ext}_{g} \wedge \mathrm{p}^{\mathrm{mod}}_{g} > 0 +``` + diff --git a/docs/examples/pypsa/generator_ramping.md b/docs/examples/pypsa/generator_ramping.md new file mode 100644 index 00000000..bcb55e43 --- /dev/null +++ b/docs/examples/pypsa/generator_ramping.md @@ -0,0 +1,390 @@ + + +# Generators, the ramping + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`, the ramping rows. It reads `Generator_active`, `Generator_big_m`, `Generator_committable`, `Generator_p`, `Generator_p_nom_committed`, `Generator_p_nom_effective` and 8 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + generator: + description: generating units, each on one bus + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Generator_ramp_limit_up: + description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] + Generator_ramp_limit_down: + description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, generator] + Generator_ramp_limit_start_up: + description: most output in the snapshot a unit starts, per unit of nominal power + dims: [scenario, generator] + Generator_ramp_limit_shut_down: + description: most output in the snapshot before a unit stops, per unit of nominal power + dims: [scenario, generator] + Generator_p_init: + description: >- + the output a unit brought into the horizon — PyPSA's `p_init`, read + only where the unit came in running; no value means it is unknown, so + the unit carries no ramp row at the first snapshot + dims: [scenario, generator] + +given: + parameters: + Generator_p_nom_extendable: { dims: [generator], dtype: bool } + Generator_committable: { dims: [generator], dtype: bool } + Generator_status_initial: { dims: [scenario, generator], dtype: int } + Generator_p_nom_mod: { dims: [generator] } + Generator_big_m: { dims: [scenario, generator] } + Generator_active: { dims: [snapshot, generator], dtype: bool } + variables: + Generator_p: { dims: [scenario, snapshot, generator] } + Generator_status: { dims: [scenario, snapshot, generator], domain: integer } + Generator_start_up: { dims: [scenario, snapshot, generator], domain: integer } + Generator_shut_down: { dims: [scenario, snapshot, generator], domain: integer } + Generator_p_nom_ext: { dims: [generator] } + expressions: + Generator_previous_status: { dims: [scenario, snapshot, generator] } + Generator_p_nom_effective: { dims: [scenario, generator] } + Generator_p_nom_committed: { dims: [scenario, generator] } + +expressions: + Generator_previous_p: + description: >- + the output a generator carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: Generator_status_initial * Generator_p_init } + otherwise: shift(Generator_p, along=snapshot, offset=1) + Generator_ramp_up_rate: + description: >- + the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, generator] + cases: + given: { when: Generator_ramp_limit_up, expression: Generator_ramp_limit_up } + otherwise: 1 + Generator_ramp_down_rate: + description: >- + the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, generator] + cases: + given: { when: Generator_ramp_limit_down, expression: Generator_ramp_limit_down } + otherwise: 1 + Generator_start_up_rate: + description: >- + the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, generator] + cases: + given: { when: Generator_ramp_limit_start_up, expression: Generator_ramp_limit_start_up } + otherwise: 1 + Generator_shut_down_rate: + description: >- + the shut-down ramp a unit's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, generator] + cases: + given: { when: Generator_ramp_limit_shut_down, expression: Generator_ramp_limit_shut_down } + otherwise: 1 + Generator_ramp_up_allowance: + description: >- + how far a generator may raise output between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_up_rate * Generator_p_nom_committed * Generator_previous_status + + Generator_start_up_rate * Generator_p_nom_committed + * (Generator_status - Generator_previous_status) + otherwise: Generator_ramp_up_rate * Generator_p_nom_effective + Generator_ramp_down_allowance: + description: >- + how far a generator may lower output between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, generator] + cases: + committed: + when: Generator_committable + expression: >- + Generator_ramp_down_rate * Generator_p_nom_committed * Generator_status + + Generator_shut_down_rate * Generator_p_nom_committed + * (Generator_previous_status - Generator_status) + otherwise: Generator_ramp_down_rate * Generator_p_nom_effective + +constraints: + Generator_p_ramp_limit_up_run_big_m: + description: >- + `Generator-p-ramp_limit_up-run-bigM` — a committed extendable unit + raises output no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_p - Generator_previous_p <= + Generator_ramp_up_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_previous_status + Generator_p_ramp_limit_up_start_big_m: + description: >- + `Generator-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable unit ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_p - Generator_previous_p <= + Generator_start_up_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_start_up + Generator_p_ramp_limit_down_run_big_m: + description: >- + `Generator-p-ramp_limit_down-run-bigM` — a committed extendable unit + lowers output no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_previous_p - Generator_p <= + Generator_ramp_down_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_status + Generator_p_ramp_limit_down_shut_big_m: + description: >- + `Generator-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable unit ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, generator] + where: >- + Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0) + AND (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: >- + Generator_previous_p - Generator_p <= + Generator_shut_down_rate * Generator_p_nom_ext + + Generator_big_m - Generator_big_m * Generator_shut_down + Generator_p_ramp_limit_up: + description: >- + `Generator-p-ramp_limit_up` — a generator raises output no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A unit that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead + dims: [scenario, snapshot, generator] + where: >- + (Generator_ramp_limit_up OR Generator_ramp_limit_start_up) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: Generator_p - Generator_previous_p <= Generator_ramp_up_allowance + Generator_p_ramp_limit_down: + description: >- + `Generator-p-ramp_limit_down` — a generator lowers output no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A unit that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the output it brought in, and no unit carries one at + the start of a later investment period — nor does any unit a big M releases instead + dims: [scenario, snapshot, generator] + where: >- + (Generator_ramp_limit_down OR Generator_ramp_limit_shut_down) + AND NOT (Generator_committable AND Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Generator_status_initial == 0 OR Generator_p_init))) + AND Generator_active + expression: Generator_previous_p - Generator_p <= Generator_ramp_down_allowance + +assumptions: + Generator_came_in_running_unless_committable: + holds: "Generator_status_initial == 1" + where: "NOT Generator_committable AND (Generator_ramp_limit_up OR Generator_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a unit that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the unit there at zero, or at its + start-up ramp where another unit of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable unit and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units, each on one bus | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$, data another file declares | +| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$, data another file declares | +| $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$, data another file declares | +| $`p`$ | `Generator_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ | +| $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ | +| $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, an expression another file defines | +| $`\widetilde{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_effective` over $`\Xi \times \mathcal{G}`$, an expression another file defines | +| $`\widehat{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_committed` over $`\Xi \times \mathcal{G}`$, an expression another file defines | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | +| $`\widetilde{\mathrm{ru}}`$ | `Generator_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}`$ | `Generator_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — the ramp limit a unit's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{\mathrm{up}}`$ | `Generator_start_up_rate` over $`\Xi \times \mathcal{G}`$ — the start-up ramp a unit's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{\mathrm{dn}}`$ | `Generator_shut_down_rate` over $`\Xi \times \mathcal{G}`$ — the shut-down ramp a unit's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\Delta^{+}`$ | `Generator_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may raise output between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{-}`$ | `Generator_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — how far a generator may lower output between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + +#### Subject to + +**`Generator_p_ramp_limit_up_run_big_m`** + +```math +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_p_ramp_limit_up_start_big_m`** + +```math +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{up}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_p_ramp_limit_down_run_big_m`** + +```math +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot u_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_p_ramp_limit_down_shut_big_m`** + +```math +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot P_{g} + \mathrm{M}_{\xi,g} - \mathrm{M}_{\xi,g} \cdot \mathit{dn}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \wedge \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_p_ramp_limit_up`** + +```math +p_{\xi,t,g} - \overleftarrow{p}_{\xi,t,g} \le \Delta^{+}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +**`Generator_p_ramp_limit_down`** + +```math +\overleftarrow{p}_{\xi,t,g} - p_{\xi,t,g} \le \Delta^{-}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{rd}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}_{g} \wedge \mathrm{ext}_{g} \wedge \neg \left( \mathrm{p}^{\mathrm{mod}}_{g} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{0}_{\xi,g} = 0 \vee \mathrm{p}^{0}_{\xi,g} \text{ is defined} \right) \right) \wedge \mathrm{on}_{t,g} +``` + +#### Definitions + +**`Generator_previous_p`** + +```math +\overleftarrow{p}_{\xi,t,g} = \begin{cases} \mathrm{u}^{0}_{\xi,g} \cdot \mathrm{p}^{0}_{\xi,g} & \text{if } \mathrm{pos}(t) = 0 \\ p_{\xi,t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`Generator_ramp_up_rate`** + +```math +\widetilde{\mathrm{ru}}_{\xi,t,g} = \begin{cases} \mathrm{ru}_{\xi,t,g} & \text{if } \mathrm{ru}_{\xi,t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`Generator_ramp_down_rate`** + +```math +\widetilde{\mathrm{rd}}_{\xi,t,g} = \begin{cases} \mathrm{rd}_{\xi,t,g} & \text{if } \mathrm{rd}_{\xi,t,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`Generator_start_up_rate`** + +```math +\widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} = \begin{cases} \mathrm{ru}^{\mathrm{up}}_{\xi,g} & \text{if } \mathrm{ru}^{\mathrm{up}}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Generator_shut_down_rate`** + +```math +\widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} = \begin{cases} \mathrm{rd}^{\mathrm{dn}}_{\xi,g} & \text{if } \mathrm{rd}^{\mathrm{dn}}_{\xi,g} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Generator_ramp_up_allowance`** + +```math +\Delta^{+}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \overleftarrow{u}_{\xi,t,g} + \widetilde{\mathrm{ru}}^{\mathrm{up}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( u_{\xi,t,g} - \overleftarrow{u}_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{ru}}_{\xi,t,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`Generator_ramp_down_allowance`** + +```math +\Delta^{-}_{\xi,t,g} = \begin{cases} \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot u_{\xi,t,g} + \widetilde{\mathrm{rd}}^{\mathrm{dn}}_{\xi,g} \cdot \widehat{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} \cdot \left( \overleftarrow{u}_{\xi,t,g} - u_{\xi,t,g} \right) & \text{if } \mathrm{com}_{g} \\ \widetilde{\mathrm{rd}}_{\xi,t,g} \cdot \widetilde{\mathrm{p}}^{\mathrm{nom}}_{\xi,g} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`Generator_came_in_running_unless_committable`** + +```math +\mathrm{u}^{0}_{\xi,g} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \neg \mathrm{com}_{g} \wedge \left( \mathrm{ru}_{\xi,t,g} \text{ is defined} \vee \mathrm{rd}_{\xi,t,g} \text{ is defined} \right) +``` + diff --git a/docs/examples/pypsa/global_constraints.md b/docs/examples/pypsa/global_constraints.md new file mode 100644 index 00000000..a6d6f723 --- /dev/null +++ b/docs/examples/pypsa/global_constraints.md @@ -0,0 +1,267 @@ + + +# Global constraints + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the five system totals PyPSA caps, each a sum the components add to. It reads `operational_limit`, `primary_energy`, `scenario_weight`, `tech_capacity_expansion`, `transmission_expansion_cost`, `transmission_volume_expansion` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + +parameters: + GlobalConstraint_type: + description: >- + which formula the row takes — `primary_energy`, `operational_limit`, + `transmission_volume_expansion_limit`, + `transmission_expansion_cost_limit` or + `tech_capacity_expansion_limit` + dims: [global_constraint] + dtype: str + GlobalConstraint_sense: + description: >- + which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA + reads a row's sense per scenario (`global_constraints.py:556`, `:748`, + `:860`) + dims: [scenario, global_constraint] + dtype: str + GlobalConstraint_constant: + description: >- + the constant the total is held against; what a variable cannot carry — + an initial charge, times its period's years for each counted period + where the storage reopens per period, or a non-extendable build — is + folded in here by data prep. PyPSA reads it per scenario + (`global_constraints.py:557`, `:749`, `:861`) + dims: [scenario, global_constraint] + +given: + parameters: + scenario_weight: { dims: [scenario] } + expressions: + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + +constraints: + GlobalConstraint_primary_energy_ub: + description: "`primary_energy` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '<=' + expression: primary_energy <= GlobalConstraint_constant + GlobalConstraint_primary_energy_lb: + description: "`primary_energy` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '>=' + expression: primary_energy >= GlobalConstraint_constant + GlobalConstraint_primary_energy_eq: + description: "`primary_energy` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'primary_energy' AND GlobalConstraint_sense == '==' + expression: primary_energy == GlobalConstraint_constant + GlobalConstraint_operational_limit_ub: + description: "`operational_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '<=' + expression: operational_limit <= GlobalConstraint_constant + GlobalConstraint_operational_limit_lb: + description: "`operational_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '>=' + expression: operational_limit >= GlobalConstraint_constant + GlobalConstraint_operational_limit_eq: + description: "`operational_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'operational_limit' AND GlobalConstraint_sense == '==' + expression: operational_limit == GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_ub: + description: "`transmission_volume_expansion_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '<=' + expression: transmission_volume_expansion <= GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_lb: + description: "`transmission_volume_expansion_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '>=' + expression: transmission_volume_expansion >= GlobalConstraint_constant + GlobalConstraint_transmission_volume_expansion_limit_eq: + description: "`transmission_volume_expansion_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_volume_expansion_limit' AND GlobalConstraint_sense == '==' + expression: transmission_volume_expansion == GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_ub: + description: "`transmission_expansion_cost_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '<=' + expression: transmission_expansion_cost <= GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_lb: + description: "`transmission_expansion_cost_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '>=' + expression: transmission_expansion_cost >= GlobalConstraint_constant + GlobalConstraint_transmission_expansion_cost_limit_eq: + description: "`transmission_expansion_cost_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'transmission_expansion_cost_limit' AND GlobalConstraint_sense == '==' + expression: transmission_expansion_cost == GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_ub: + description: "`tech_capacity_expansion_limit` — its total, at most its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '<=' + expression: tech_capacity_expansion <= GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_lb: + description: "`tech_capacity_expansion_limit` — its total, at least its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '>=' + expression: tech_capacity_expansion >= GlobalConstraint_constant + GlobalConstraint_tech_capacity_expansion_limit_eq: + description: "`tech_capacity_expansion_limit` — its total, at its constant" + dims: [scenario, global_constraint] + where: GlobalConstraint_type == 'tech_capacity_expansion_limit' AND GlobalConstraint_sense == '==' + expression: tech_capacity_expansion == GlobalConstraint_constant + +assumptions: + GlobalConstraint_tech_capacity_expansion_limit_without_scenarios: + holds: "GlobalConstraint_type != 'tech_capacity_expansion_limit'" + where: "count(scenario_weight, over=scenario) > 1" + description: >- + PyPSA does not build a `tech_capacity_expansion_limit` row on a + network with scenarios and refuses it + (`global_constraints.py:66-68`). The spec cannot tell a network with + one scenario from one with none, so it refuses only where there is + more than one scenario +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{G}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | +| $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\Xi \times \mathcal{G}`$ — which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA reads a row's sense per scenario (`global_constraints.py:556`, `:748`, `:860`) | +| $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\Xi \times \mathcal{G}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep. PyPSA reads it per scenario (`global_constraints.py:557`, `:749`, `:861`) | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression another file defines | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression another file defines | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression another file defines | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression another file defines | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression another file defines | + +#### Subject to + +**`GlobalConstraint_primary_energy_ub`** + +```math +\mathit{primary\_energy}_{\xi,g} \le \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{<=}\text{'} +``` + +**`GlobalConstraint_primary_energy_lb`** + +```math +\mathit{primary\_energy}_{\xi,g} \ge \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{>=}\text{'} +``` + +**`GlobalConstraint_primary_energy_eq`** + +```math +\mathit{primary\_energy}_{\xi,g} = \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{primary\_energy}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{==}\text{'} +``` + +**`GlobalConstraint_operational_limit_ub`** + +```math +\mathit{operational\_limit}_{\xi,g} \le \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{<=}\text{'} +``` + +**`GlobalConstraint_operational_limit_lb`** + +```math +\mathit{operational\_limit}_{\xi,g} \ge \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{>=}\text{'} +``` + +**`GlobalConstraint_operational_limit_eq`** + +```math +\mathit{operational\_limit}_{\xi,g} = \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{operational\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{==}\text{'} +``` + +**`GlobalConstraint_transmission_volume_expansion_limit_ub`** + +```math +\mathit{transmission\_volume\_expansion}_{\xi,g} \le \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{<=}\text{'} +``` + +**`GlobalConstraint_transmission_volume_expansion_limit_lb`** + +```math +\mathit{transmission\_volume\_expansion}_{\xi,g} \ge \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{>=}\text{'} +``` + +**`GlobalConstraint_transmission_volume_expansion_limit_eq`** + +```math +\mathit{transmission\_volume\_expansion}_{\xi,g} = \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_volume\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{==}\text{'} +``` + +**`GlobalConstraint_transmission_expansion_cost_limit_ub`** + +```math +\mathit{transmission\_expansion\_cost}_{\xi,g} \le \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{<=}\text{'} +``` + +**`GlobalConstraint_transmission_expansion_cost_limit_lb`** + +```math +\mathit{transmission\_expansion\_cost}_{\xi,g} \ge \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{>=}\text{'} +``` + +**`GlobalConstraint_transmission_expansion_cost_limit_eq`** + +```math +\mathit{transmission\_expansion\_cost}_{\xi,g} = \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{transmission\_expansion\_cost\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{==}\text{'} +``` + +**`GlobalConstraint_tech_capacity_expansion_limit_ub`** + +```math +\mathit{tech\_capacity\_expansion}_{g} \le \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{<=}\text{'} +``` + +**`GlobalConstraint_tech_capacity_expansion_limit_lb`** + +```math +\mathit{tech\_capacity\_expansion}_{g} \ge \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{>=}\text{'} +``` + +**`GlobalConstraint_tech_capacity_expansion_limit_eq`** + +```math +\mathit{tech\_capacity\_expansion}_{g} = \mathrm{K}_{\xi,g} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} \,:\, \mathrm{type}_{g} = \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \wedge \mathrm{sense}_{\xi,g} = \text{'}\mathrm{==}\text{'} +``` + +#### Assumptions + +**`GlobalConstraint_tech_capacity_expansion_limit_without_scenarios`** + +```math +\mathrm{type}_{g} \neq \text{'}\mathrm{tech\_capacity\_expansion\_limit}\text{'} \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ \xi \in \Xi \,:\, \pi_{\xi} \text{ is defined} \} \rvert > 1 +``` + diff --git a/docs/examples/pypsa/index.md b/docs/examples/pypsa/index.md new file mode 100644 index 00000000..bf11286f --- /dev/null +++ b/docs/examples/pypsa/index.md @@ -0,0 +1,89 @@ + + +# PyPSA in 24 files + +[PyPSA in one file](../pypsa.md) is `examples/pypsa.yaml`, one spec of some +four thousand lines. This is the same spec as 24 files under +`examples/pypsa/`, one per topic, and `merge` gives the one file back: the +two have one [canonical form](../../howto/compare.md). + +Every fragment loads, prints and gets advice on its own. What it reads and does +not declare, it states under +[`given`](../../reference/language/declarations.md#given). A component that +puts something into a sum every component adds to, such as the bus balance or +the operating cost, names its share as an expression of its own and adds it +with a [`term`](../../reference/language/declarations.md#a-term-a-file-adds). +One fragment reads each sum with its description, so a term always lands. A +new component is one new file, and the network does not change. + +The split is written by `tools/pypsa_split.py` from the one file and checked +against it, so the two cannot drift. It is a proof of concept: a decision on +which of the two is the source comes after both land. + +## What each file says + +Three kinds of file. A **component** owns PyPSA's class of that name: its +dimension, its data, its columns, its rows, and its share of each sum. The +committable classes, `Generator`, `Link` and `Process`, are cut by feature into +a file each for the class, its commitment, its ramping and its maintenance, +and the three sets read alike because PyPSA's rows do. A **reader** owns a sum +and the row that reads it: the network reads `Bus_injection`, power flow reads +`Cycle_angle_sum`. **Settings** holds what every topic reads, the weightings +and the flags, and the readings of the totals whose own readers, the cost, the +carriers and the global constraints, a model may leave out. + + +### The sums + +| Sum | Over | Read in | The terms, by the fragment that adds each | +| --- | --- | --- | --- | +| `scenario_opex` | `scenario` | [settings](settings.md) | [`Generator_opex`](generator.md), [`Generator_commitment_opex`](generator_commitment.md), [`Link_opex`](link.md), [`Link_commitment_opex`](link_commitment.md), [`Process_opex`](process.md), [`Process_commitment_opex`](process_commitment.md), [`StorageUnit_opex`](storage_unit.md), [`Store_opex`](store.md) | +| `Bus_injection` | `scenario, snapshot, bus` | [network](network.md) | [`Generator_injection`](generator.md), [`Line_injection`](line.md), [`Link_injection`](link.md), [`Load_injection`](load.md), [`Process_injection`](process.md), [`StorageUnit_injection`](storage_unit.md), [`Store_injection`](store.md), [`Transformer_injection`](transformer.md) | +| `tech_capacity_expansion` | `global_constraint` | [settings](settings.md) | [`Generator_tech_capacity_expansion`](generator.md), [`Line_tech_capacity_expansion`](line.md), [`Link_tech_capacity_expansion`](link.md), [`Process_tech_capacity_expansion`](process.md), [`StorageUnit_tech_capacity_expansion`](storage_unit.md), [`Store_tech_capacity_expansion`](store.md) | +| `Carrier_additions` | `period, carrier` | [settings](settings.md) | [`Generator_additions`](generator.md), [`Line_additions`](line.md), [`Link_additions`](link.md), [`Process_additions`](process.md), [`StorageUnit_additions`](storage_unit.md), [`Store_additions`](store.md) | +| `primary_energy` | `scenario, global_constraint` | [settings](settings.md) | [`Generator_primary_energy`](generator.md), [`StorageUnit_primary_energy`](storage_unit.md), [`Store_primary_energy`](store.md) | +| `operational_limit` | `scenario, global_constraint` | [settings](settings.md) | [`Generator_operational_limit`](generator.md), [`StorageUnit_operational_limit`](storage_unit.md), [`Store_operational_limit`](store.md) | +| `transmission_volume_expansion` | `scenario, global_constraint` | [settings](settings.md) | [`Line_transmission_volume_expansion`](line.md), [`Link_transmission_volume_expansion`](link.md) | +| `transmission_expansion_cost` | `scenario, global_constraint` | [settings](settings.md) | [`Line_transmission_expansion_cost`](line.md), [`Link_transmission_expansion_cost`](link.md) | +| `Cycle_angle_sum` | `scenario, snapshot, cycle` | [power_flow](power_flow.md) | [`Line_angle_sum`](line.md), [`Transformer_angle_sum`](transformer.md) | + +### The fragments + +| Fragment | Parameters | Variables | Constraints | Reads | Adds to | +| --- | --- | --- | --- | --- | --- | +| [carrier](carrier.md) | 2 | 0 | 1 | 1 | | +| [cost](cost.md) | 1 | 3 | 2 | 3 | | +| [generator](generator.md) | 23 | 3 | 11 | 16 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [generator_commitment](generator_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | +| [generator_maintenance](generator_maintenance.md) | 5 | 4 | 13 | 9 | | +| [generator_ramping](generator_ramping.md) | 5 | 0 | 6 | 14 | | +| [global_constraints](global_constraints.md) | 3 | 0 | 15 | 6 | | +| [line](line.md) | 18 | 3 | 11 | 8 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum` | +| [link](link.md) | 23 | 3 | 9 | 14 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [link_commitment](link_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | +| [link_maintenance](link_maintenance.md) | 5 | 4 | 13 | 9 | | +| [link_ramping](link_ramping.md) | 5 | 0 | 6 | 14 | | +| [load](load.md) | 3 | 0 | 0 | 1 | `Bus_injection` | +| [network](network.md) | 0 | 0 | 1 | 1 | | +| [power_flow](power_flow.md) | 0 | 0 | 1 | 1 | | +| [process](process.md) | 21 | 3 | 9 | 12 | `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [process_commitment](process_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | +| [process_maintenance](process_maintenance.md) | 5 | 4 | 13 | 9 | | +| [process_ramping](process_ramping.md) | 5 | 0 | 6 | 14 | | +| [security](security.md) | 2 | 0 | 8 | 10 | | +| [settings](settings.md) | 9 | 0 | 0 | 7 | | +| [storage_unit](storage_unit.md) | 34 | 5 | 20 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [store](store.md) | 27 | 3 | 10 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [transformer](transformer.md) | 19 | 4 | 11 | 4 | `Bus_injection`, `Cycle_angle_sum` | + + +## Leaving a file out + +A model may leave a component family out, or the cost, the carriers, the +global constraints or security, and what is left is a whole model: nothing +stays under `given:`. It may not leave the network or power flow out while a +component is in, because the component's terms would land on no name, and +`merge` says so. diff --git a/docs/examples/pypsa/line.md b/docs/examples/pypsa/line.md new file mode 100644 index 00000000..6d272bc7 --- /dev/null +++ b/docs/examples/pypsa/line.md @@ -0,0 +1,474 @@ + + +# Lines + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Line`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + line: + description: passive branches, each between two buses, their flow set by impedance + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + segment: + description: >- + the cuts a passive branch's loss curve is held above — PyPSA's tangents, + as many as its `segments` count, or its secants, as many as its tolerance + loop places; none in a lossless run + dtype: int + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + Line_carrier: + description: the carrier a line carries + key: line + values: carrier + Line_bus0: + description: the bus a line's flow is measured at + key: line + values: bus + Line_bus1: + description: the bus at a line's other end + key: line + values: bus + +parameters: + Line_active: + description: whether a line stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, line] + dtype: bool + Line_capital_weight: + description: the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [line] + Line_first_active: + description: >- + one in the first period a line stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a line + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, line] + Line_s_nom: + description: nominal apparent power + dims: [scenario, line] + Line_s_nom_extendable: + description: whether the nominal apparent power is a decision + dims: [line] + dtype: bool + Line_s_max_pu: + description: most flow either way, per unit of nominal apparent power + dims: [scenario, snapshot, line] + Line_s_nom_min: + description: least nominal apparent power an extendable line may be built at + dims: [scenario, line] + Line_s_nom_max: + description: most nominal apparent power an extendable line may be built at + dims: [scenario, line] + Line_capital_cost: + description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, line] + Line_s_nom_set: + description: a given nominal apparent power for an extendable line; one without a value has no row here + dims: [scenario, line] + Line_s_set: + description: a given flow schedule; a line without one has no row here + dims: [scenario, snapshot, line] + Line_cycle_weight: + description: >- + the line's series impedance, signed by its orientation in the cycle — + the cycle basis, data prep; a line in no cycle has no row. PyPSA builds + the cycle basis from the first scenario only (`networks.py:1354-1361`) + dims: [line, cycle] + Line_loss_max: + description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep + dims: [scenario, snapshot, line] + Line_loss_slope: + description: >- + the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` + at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between + consecutive breakpoints, data prep + dims: [scenario, snapshot, line, segment] + Line_loss_offset: + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep + dims: [scenario, snapshot, line, segment] + Line_volume_weight: + description: >- + the line's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a + line outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [scenario, global_constraint, line] + Line_expansion_cost_weight: + description: >- + the line's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + line outside the set, or one that does not stand in the row's period, + has no row + dims: [scenario, global_constraint, line] + Line_tech_capacity_weight: + description: >- + one where the line is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, line] + +variables: + Line_s: + description: >- + `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a + positive value withdraws there and injects at `Line_bus1`, lossless + dims: [scenario, snapshot, line] + where: Line_active + Line_loss: + description: >- + `Line-loss` — what a line dissipates carrying its flow, pushed down by the + cost and held up by the cuts; absent, and zero in the balance, where + the network is lossless + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + absence: zero + bounds: + lower: 0 + Line_s_nom_ext: + description: >- + `Line-s_nom` — nominal apparent power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [line] + where: Line_s_nom_extendable + +given: + parameters: + scenario_weight: { dims: [scenario] } + transmission_losses: { dims: [], dtype: bool } + expressions: + transmission_volume_expansion: { dims: [scenario, global_constraint], term: Line_transmission_volume_expansion } + transmission_expansion_cost: { dims: [scenario, global_constraint], term: Line_transmission_expansion_cost } + tech_capacity_expansion: { dims: [global_constraint], term: Line_tech_capacity_expansion } + Carrier_additions: { dims: [period, carrier], term: Line_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Line_injection } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Line_angle_sum } + +expressions: + Line_transmission_volume_expansion: + expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) + Line_transmission_expansion_cost: + expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + Line_tech_capacity_expansion: + expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + Line_additions: + expression: >- + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + Line_s_monitored: + description: >- + the flow a line's post-contingency rows read — its flow where it stands, + nothing where it does not, since PyPSA builds those rows for every + branch of the sub-network in every snapshot + dims: [scenario, snapshot, line] + cases: + standing: { when: Line_active, expression: Line_s } + otherwise: 0 + Line_injection: + expression: >- + -sum(Line_s, by=Line_bus0, over=line, into=bus) + + sum(Line_s, by=Line_bus1, over=line, into=bus) + - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) + - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) + Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) + +constraints: + Line_fix_s_lower: + description: "`Line-fix-s-lower` — a fixed line carries at least the negative of its rating, the loss counted against it" + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom + Line_fix_s_upper: + description: "`Line-fix-s-upper` — a fixed line carries at most its rating, the loss included" + dims: [scenario, snapshot, line] + where: not Line_s_nom_extendable AND Line_active + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom + Line_ext_s_lower: + description: "`Line-ext-s-lower` — an extendable line carries at least the negative of its rating of the chosen build, the loss counted against it" + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active + expression: Line_s - Line_loss >= -Line_s_max_pu * Line_s_nom_ext + Line_ext_s_upper: + description: "`Line-ext-s-upper` — an extendable line carries at most its rating of the chosen build, the loss included" + dims: [scenario, snapshot, line] + where: Line_s_nom_extendable AND Line_active + expression: Line_s + Line_loss <= Line_s_max_pu * Line_s_nom_ext + Line_ext_s_nom_lower: + description: "`Line-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, line] + where: Line_s_nom_extendable + expression: Line_s_nom_ext >= Line_s_nom_min + Line_ext_s_nom_upper: + description: "`Line-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, line] + where: Line_s_nom_extendable AND Line_s_nom_max + expression: Line_s_nom_ext <= Line_s_nom_max + Line_s_nom_set: + description: "`Line-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, line] + where: Line_s_nom_extendable AND Line_s_nom_set + expression: Line_s_nom_ext == Line_s_nom_set + Line_s_set: + description: "`Line-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, line] + where: Line_s_set AND Line_active + expression: Line_s == Line_s_set + Line_loss_upper: + description: "`Line-loss_upper` — a line dissipates at most the loss at its rating" + dims: [scenario, snapshot, line] + where: transmission_losses AND Line_active + expression: Line_loss <= Line_loss_max + Line_loss_tangents_forward: + description: >- + `Line-loss_tangents-{k}-1`, `Line-loss_secants-pos` — the loss sits above + every cut to its curve for flow one way; PyPSA names one row per tangent + `k`, or one row stacked over its `secant` axis, and this block states them + all over the segment dimension + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss + Line_loss_slope * Line_s >= Line_loss_offset + Line_loss_tangents_reverse: + description: >- + `Line-loss_tangents-{k}--1`, `Line-loss_secants-neg` — the same fan + mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, line, segment] + where: transmission_losses AND Line_active + expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Line_s_nom_ext) * Line_capital_cost) * Line_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N}`$ — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | +| $`\mathcal{E}`$ | index $`e`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{on}^{s}`$ | `Line_active` over $`\mathcal{T} \times \mathcal{K}`$ — whether a line stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{s}`$ | `Line_capital_weight` over $`\mathcal{K}`$ — the sum of period weights a line stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a line that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\Xi \times \mathcal{K}`$ — nominal apparent power | +| $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | +| $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | +| $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\Xi \times \mathcal{K}`$ — least nominal apparent power an extendable line may be built at | +| $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\Xi \times \mathcal{K}`$ — most nominal apparent power an extendable line may be built at | +| $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\Xi \times \mathcal{K}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | +| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | +| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) | +| $`\overline{\ell}`$ | `Line_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | +| $`\mathrm{a}`$ | `Line_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{E}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | +| $`\mathrm{b}`$ | `Line_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{E}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | +| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | +| $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar), data another file declares | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Line_transmission_volume_expansion` to | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Line_transmission_expansion_cost` to | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Line_tech_capacity_expansion` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Line_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Line_injection` to | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression this file adds `Line_angle_sum` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\mathit{Line\_transmission\_volume\_expansion}`$ | `Line_transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Line\_transmission\_expansion\_cost}`$ | `Line_transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Line\_tech\_capacity\_expansion}`$ | `Line_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{Line\_additions}`$ | `Line_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | +| $`\mathit{Line\_injection}`$ | `Line_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Line\_angle\_sum}`$ | `Line_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} +``` + +#### Subject to + +**`Line_fix_s_lower`** + +```math +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_fix_s_upper`** + +```math +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_ext_s_lower`** + +```math +s_{\xi,t,k} - \ell_{\xi,t,k} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_ext_s_upper`** + +```math +s_{\xi,t,k} + \ell_{\xi,t,k} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_ext_s_nom_lower`** + +```math +S_{k} \ge \underline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} +``` + +**`Line_ext_s_nom_upper`** + +```math +S_{k} \le \overline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \overline{\mathrm{s}}^{\mathrm{nom}}_{\xi,k} \text{ is defined} +``` + +**`Line_s_nom_set`** + +```math +S_{k} = \mathrm{s}^{\mathrm{nom,set}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} \wedge \mathrm{s}^{\mathrm{nom,set}}_{\xi,k} \text{ is defined} +``` + +**`Line_s_set`** + +```math +s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{\xi,t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{s}^{\mathrm{set}}_{\xi,t,k} \text{ is defined} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_loss_upper`** + +```math +\ell_{\xi,t,k} \le \overline{\ell}_{\xi,t,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_loss_tangents_forward`** + +```math +\ell_{\xi,t,k} + \mathrm{a}_{\xi,t,k,e} \cdot s_{\xi,t,k} \ge \mathrm{b}_{\xi,t,k,e} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ e \in \mathcal{E} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_loss_tangents_reverse`** + +```math +\ell_{\xi,t,k} - \mathrm{a}_{\xi,t,k,e} \cdot s_{\xi,t,k} \ge \mathrm{b}_{\xi,t,k,e} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ e \in \mathcal{E} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +#### Definitions + +**`Line_transmission_volume_expansion`** + +```math +\mathit{Line\_transmission\_volume\_expansion}_{\xi,g} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{len}_{\xi,g,k} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Line_transmission_expansion_cost`** + +```math +\mathit{Line\_transmission\_expansion\_cost}_{\xi,g} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{cc}_{\xi,g,k} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Line_tech_capacity_expansion`** + +```math +\mathit{Line\_tech\_capacity\_expansion}_{g} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{g,k} \qquad \forall\, g \in \mathcal{G} +``` + +**`Line_additions`** + +```math +\mathit{Line\_additions}_{y,i} = \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_carrier}(k) = i} S_{k} \cdot \mathrm{new}^{s}_{y,k} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`Line_s_monitored`** + +```math +\check{s}_{\xi,t,k} = \begin{cases} s_{\xi,t,k} & \text{if } \mathrm{on}^{s}_{t,k} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} +``` + +**`Line_injection`** + +```math +\mathit{Line\_injection}_{\xi,t,n} = -\left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} s_{\xi,t,k} \right) + \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} s_{\xi,t,k} - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus0}(k) = n} \ell_{\xi,t,k} \right) - 0.5 \cdot \left( \sum_{k \in \mathcal{K} \,:\, \mathrm{Line\_bus1}(k) = n} \ell_{\xi,t,k} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +**`Line_angle_sum`** + +```math +\mathit{Line\_angle\_sum}_{\xi,t,c} = \sum_{k \in \mathcal{K}} s_{\xi,t,k} \cdot \mathrm{x}_{k,c} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + +#### Variable domains + +**`Line_s`** + +```math +s_{\xi,t,k} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{on}^{s}_{t,k} +``` + +**`Line_loss`** + +```math +\ell_{\xi,t,k} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{s}_{t,k} +``` + +**`Line_s_nom_ext`** + +```math +S_{k} \in \mathbb{R} \qquad \forall\, k \in \mathcal{K} \,:\, \mathrm{ext}^{s}_{k} +``` + diff --git a/docs/examples/pypsa/link.md b/docs/examples/pypsa/link.md new file mode 100644 index 00000000..34487f50 --- /dev/null +++ b/docs/examples/pypsa/link.md @@ -0,0 +1,557 @@ + + +# Links + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Link_committable`, `Link_maintenance`, `Link_maintenance_capacity`, `Link_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + link: + description: controllable connections, each from one bus to the buses it delivers to + link_output: + description: >- + a link's output ports, one label per port a link declares — PyPSA's + `bus1`, `bus2`, … columns read long, so a link of any number of output + ports is one term in the balance, data prep + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Link_carrier: + description: the carrier a link converts from + key: link + values: carrier + Link_bus0: + description: the bus a link leaves + key: link + values: bus + Link_output_link: + description: the link an output port belongs to + key: link_output + values: link + Link_output_bus: + description: >- + the bus an output port delivers to — PyPSA's `bus1`, `bus2`, … columns. + A link of three output ports is three labels here rather than a third + relation, so the file states any number of them + key: link_output + values: bus + +parameters: + Link_p_nom: + description: nominal power + dims: [scenario, link] + Link_p_nom_extendable: + description: whether the nominal power is a decision + dims: [link] + dtype: bool + Link_p_min_pu: + description: least flow, per unit of nominal power — negative for a link that carries both ways + dims: [scenario, snapshot, link] + Link_p_max_pu: + description: most flow, per unit of nominal power + dims: [scenario, snapshot, link] + Link_efficiency: + description: >- + share of the flow that arrives at an output port, PyPSA's `efficiency`, + `efficiency2`, … read long — negative where that port consumes rather + than delivers + dims: [scenario, link_output] + Link_output_delay: + description: >- + snapshots a port's delivery lags its link's flow — PyPSA's `delay`, + `delay2`, … read long, in `snapshot_weightings.generators` units, which + the file states as whole snapshots; zero for a port that delivers at once. + Each scenario takes its own. PyPSA `1.3.0` groups the ports by delay over + all scenarios and shifts each group in every one, so a delay that differs + by scenario delivers the flow twice (`constraints.py:1269-1276`, + PyPSA/PyPSA#1941) + dims: [scenario, link_output] + dtype: int + Link_output_cyclic_delay: + description: >- + whether a delayed port's flow wraps from the end of its investment + period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, + the flow still in transit at each period's first snapshots is lost. Each + scenario takes its own, as the delay + dims: [scenario, link_output] + dtype: bool + Link_marginal_cost: + description: cost of one unit of flow + dims: [scenario, snapshot, link] + Link_marginal_cost_quadratic: + description: cost of the square of one unit of flow + dims: [scenario, snapshot, link] + Link_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [link] + Link_modules_installed: + description: >- + how many whole modules a committable build has in place: `Link_p_nom + / Link_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, link] + Link_p_min_pu_nonneg: + description: >- + true where none of the link's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [link] + dtype: bool + Link_active: + description: whether a link stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, link] + dtype: bool + Link_capital_weight: + description: the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [link] + Link_first_active: + description: >- + one in the first period a link stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a link + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, link] + Link_p_set: + description: a given flow schedule; a link without one has no row here + dims: [scenario, snapshot, link] + Link_p_nom_min: + description: least nominal power an extendable link may be built at + dims: [scenario, link] + Link_p_nom_max: + description: most nominal power an extendable link may be built at + dims: [scenario, link] + Link_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, link] + Link_p_nom_set: + description: a given nominal power for an extendable link; one without a value has no row here + dims: [scenario, link] + Link_volume_weight: + description: >- + the link's length where its carrier is in the row's set, the first + scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — + data prep; a + link outside it, or one that does not stand in the row's + `investment_period`, has no row + dims: [scenario, global_constraint, link] + Link_expansion_cost_weight: + description: >- + the link's capital cost where its carrier is in the row's set, times + the objective weights of the periods it stands in where the row names + no `investment_period` under `multi_investment_periods` — data prep; a + link outside the set, or one that does not stand in the row's period, + has no row + dims: [scenario, global_constraint, link] + Link_tech_capacity_weight: + description: >- + one where the link is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, link] + +variables: + Link_p: + description: >- + `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a + positive value withdraws there and injects at every bus the link's + output ports deliver to + dims: [scenario, snapshot, link] + where: Link_active + Link_n_mod: + description: "`Link-n_mod` — how many modules of an extendable modular build" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Link_p_nom_ext: + description: >- + `Link-p_nom` — nominal power where it is a decision; the parameter of + the same PyPSA name carries the fixed regime + dims: [link] + where: Link_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Link_committable: { dims: [link], dtype: bool } + Link_maintenance_pu: { dims: [scenario, link] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + variables: + Link_maintenance: { dims: [scenario, snapshot, link] } + Link_maintenance_capacity: { dims: [scenario, snapshot, link] } + expressions: + transmission_volume_expansion: { dims: [scenario, global_constraint], term: Link_transmission_volume_expansion } + transmission_expansion_cost: { dims: [scenario, global_constraint], term: Link_transmission_expansion_cost } + tech_capacity_expansion: { dims: [global_constraint], term: Link_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Link_opex } + Carrier_additions: { dims: [period, carrier], term: Link_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Link_injection } + +expressions: + Link_p_nom_effective: + description: the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, link] + cases: + extendable: { when: Link_p_nom_extendable, expression: Link_p_nom_ext } + otherwise: Link_p_nom + Link_p_nom_committed: + description: >- + the build a committed link's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, link] + cases: + modular_build: { when: Link_p_nom_extendable AND Link_p_nom_mod > 0, expression: Link_p_nom_mod } + otherwise: Link_p_nom + Link_output_arrival: + description: >- + what a link delivers to an output port at a snapshot — its flow after the + port's efficiency, delayed by the port's `delay` within its investment + period; where the port is `cyclic_delay` the delayed flow wraps from the + period's end, and where it is not the flow still in transit at the + period's first snapshots is lost. A port that does not delay (`delay` + zero) delivers its flow unshifted, cyclic or not + dims: [scenario, snapshot, link_output] + cases: + wrapping: + when: Link_output_cyclic_delay + expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) + Link_transmission_volume_expansion: + expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) + Link_transmission_expansion_cost: + expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + Link_tech_capacity_expansion: + expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) + Link_opex: + expression: >- + sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + Link_additions: + expression: >- + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + Link_injection: + expression: >- + -sum(Link_p, by=Link_bus0, over=link, into=bus) + + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + +constraints: + Link_fix_p_lower: + description: "`Link-fix-p-lower` — a fixed link carries at least its minimum, negative for the other way" + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) + Link_fix_p_upper: + description: "`Link-fix-p-upper` — a fixed link carries at most its nominal power" + dims: [scenario, snapshot, link] + where: not Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * (1 - Link_maintenance_pu * Link_maintenance) + Link_ext_p_lower: + description: "`Link-ext-p-lower` — an extendable link carries at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p >= Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_ext_p_upper: + description: "`Link-ext-p-upper` — an extendable link carries at most the chosen build" + dims: [scenario, snapshot, link] + where: Link_p_nom_extendable AND not Link_committable AND Link_active + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_ext_p_nom_lower: + description: "`Link-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, link] + where: Link_p_nom_extendable + expression: Link_p_nom_ext >= Link_p_nom_min + Link_ext_p_nom_upper: + description: "`Link-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, link] + where: Link_p_nom_extendable AND Link_p_nom_max + expression: Link_p_nom_ext <= Link_p_nom_max + Link_p_nom_set: + description: "`Link-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, link] + where: Link_p_nom_extendable AND Link_p_nom_set + expression: Link_p_nom_ext == Link_p_nom_set + Link_p_nom_modularity: + description: "`Link-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [link] + where: Link_p_nom_extendable AND Link_p_nom_mod > 0 + expression: Link_p_nom_ext == Link_p_nom_mod * Link_n_mod + Link_p_set: + description: "`Link-p_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, link] + where: Link_p_set AND Link_active + expression: Link_p == Link_p_set + +assumptions: + Link_marginal_cost_quadratic_without_risk_preference: + holds: "Link_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Link_p_nom_ext) * Link_capital_cost) * Link_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I},\ \mathrm{Link\_bus0}: \mathcal{L} \to \mathcal{N},\ \mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L}`$ — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{O}`$ | index $`o`$ — `link_output` with $`\mathrm{Link\_output\_link}: \mathcal{O} \to \mathcal{L},\ \mathrm{Link\_output\_bus}: \mathcal{O} \to \mathcal{N}`$ — a link's output ports, one label per port a link declares — PyPSA's `bus1`, `bus2`, … columns read long, so a link of any number of output ports is one term in the balance, data prep | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Link\_carrier}: \mathcal{L} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\Xi \times \mathcal{L}`$ — nominal power | +| $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$ — whether the nominal power is a decision | +| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | +| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most flow, per unit of nominal power | +| $`\eta`$ | `Link_efficiency` over $`\Xi \times \mathcal{O}`$ — share of the flow that arrives at an output port, PyPSA's `efficiency`, `efficiency2`, … read long — negative where that port consumes rather than delivers | +| $`\mathrm{d}^{f}`$ | `Link_output_delay` over $`\Xi \times \mathcal{O}`$ — snapshots a port's delivery lags its link's flow — PyPSA's `delay`, `delay2`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that delivers at once. Each scenario takes its own. PyPSA `1.3.0` groups the ports by delay over all scenarios and shifts each group in every one, so a delay that differs by scenario delivers the flow twice (`constraints.py:1269-1276`, PyPSA/PyPSA\#1941) | +| $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\Xi \times \mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | +| $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | +| $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\Xi \times \mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{f}`$ | `Link_capital_weight` over $`\mathcal{L}`$ — the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}^{f}`$ | `Link_first_active` over $`\mathcal{Y} \times \mathcal{L}`$ — one in the first period a link stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a link that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | +| $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\Xi \times \mathcal{L}`$ — least nominal power an extendable link may be built at | +| $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\Xi \times \mathcal{L}`$ — most nominal power an extendable link may be built at | +| $`\mathrm{c}^{\mathrm{cap},f}`$ | `Link_capital_cost` over $`\Xi \times \mathcal{L}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$ — a given nominal power for an extendable link; one without a value has no row here | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row | +| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Link_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-p` — PyPSA's `p0`, the flow measured at the `Link_bus0` end: a positive value withdraws there and injects at every bus the link's output ports deliver to | +| $`N^{f}`$ | `Link_n_mod` over $`\mathcal{L}`$ — `Link-n_mod` — how many modules of an extendable modular build | +| $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ — `Link-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$, data another file declares | +| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Link_transmission_volume_expansion` to | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Link_transmission_expansion_cost` to | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Link_tech_capacity_expansion` to | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Link_opex` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Link_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Link_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\Xi \times \mathcal{L}`$ — the build a link's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\Xi \times \mathcal{L}`$ — the build a committed link's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\overrightarrow{f}`$ | `Link_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{O}`$ — what a link delivers to an output port at a snapshot — its flow after the port's efficiency, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed flow wraps from the period's end, and where it is not the flow still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) delivers its flow unshifted, cyclic or not | +| $`\mathit{Link\_transmission\_volume\_expansion}`$ | `Link_transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Link\_transmission\_expansion\_cost}`$ | `Link_transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Link\_tech\_capacity\_expansion}`$ | `Link_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{Link\_opex}`$ | `Link_opex` over $`\Xi`$ | +| $`\mathit{Link\_additions}`$ | `Link_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Link\_injection}`$ | `Link_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +$`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $`t \boxminus_{v}^{\mathrm{relation}(t)} k`$ is both at once. + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} +``` + +#### Subject to + +**`Link_fix_p_lower`** + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \gamma^{f}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_fix_p_upper`** + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \gamma^{f}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_ext_p_lower`** + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_ext_p_upper`** + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \neg \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_ext_p_nom_lower`** + +```math +F_{l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} +``` + +**`Link_ext_p_nom_upper`** + +```math +F_{l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \text{ is defined} +``` + +**`Link_p_nom_set`** + +```math +F_{l} = \mathrm{f}^{\mathrm{nom,set}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{nom,set}}_{\xi,l} \text{ is defined} +``` + +**`Link_p_nom_modularity`** + +```math +F_{l} = \mathrm{f}^{\mathrm{mod}}_{l} \cdot N^{f}_{l} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + +**`Link_p_set`** + +```math +f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{f}^{\mathrm{set}}_{\xi,t,l} \text{ is defined} \wedge \mathrm{on}^{f}_{t,l} +``` + +#### Definitions + +**`Link_p_nom_effective`** + +```math +\widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} = \begin{cases} F_{l} & \text{if } \mathrm{ext}^{f}_{l} \\ \mathrm{f}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Link_p_nom_committed`** + +```math +\widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} = \begin{cases} \mathrm{f}^{\mathrm{mod}}_{l} & \text{if } \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \\ \mathrm{f}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Link_output_arrival`** + +```math +\overrightarrow{f}_{\xi,t,o} = \begin{cases} f_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{if } \mathrm{cyc}^{f}_{\xi,o} \\ f_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{f},\mathrm{Link\_output\_link}(o)} \cdot \eta_{\xi,o} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ o \in \mathcal{O} +``` + +**`Link_transmission_volume_expansion`** + +```math +\mathit{Link\_transmission\_volume\_expansion}_{\xi,g} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{len}^{f}_{\xi,g,l} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Link_transmission_expansion_cost`** + +```math +\mathit{Link\_transmission\_expansion\_cost}_{\xi,g} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{cc}^{f}_{\xi,g,l} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Link_tech_capacity_expansion`** + +```math +\mathit{Link\_tech\_capacity\_expansion}_{g} = \sum_{l \in \mathcal{L}} F_{l} \cdot \mathrm{m}^{f}_{g,l} \qquad \forall\, g \in \mathcal{G} +``` + +**`Link_opex`** + +```math +\mathit{Link\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +**`Link_additions`** + +```math +\mathit{Link\_additions}_{y,i} = \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_carrier}(l) = i} F_{l} \cdot \mathrm{new}^{f}_{y,l} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`Link_injection`** + +```math +\mathit{Link\_injection}_{\xi,t,n} = -\left( \sum_{l \in \mathcal{L} \,:\, \mathrm{Link\_bus0}(l) = n} f_{\xi,t,l} \right) + \sum_{o \in \mathcal{O} \,:\, \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{\xi,t,o} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Link_p`** + +```math +f_{\xi,t,l} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{on}^{f}_{t,l} +``` + +**`Link_n_mod`** + +```math +N^{f}_{l} \ge 0, N^{f}_{l} \in \mathbb{Z} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + +**`Link_p_nom_ext`** + +```math +F_{l} \in \mathbb{R} \qquad \forall\, l \in \mathcal{L} \,:\, \mathrm{ext}^{f}_{l} +``` + +#### Assumptions + +**`Link_marginal_cost_quadratic_without_risk_preference`** + +```math +\mathrm{c}^{f,(2)}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \omega > 0 +``` + diff --git a/docs/examples/pypsa/link_commitment.md b/docs/examples/pypsa/link_commitment.md new file mode 100644 index 00000000..894f7b60 --- /dev/null +++ b/docs/examples/pypsa/link_commitment.md @@ -0,0 +1,491 @@ + + +# Links, the commitment + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`, the commitment rows. It adds a term to `scenario_opex`. It reads `Link_active`, `Link_maintenance_capacity`, `Link_maintenance_pu`, `Link_maintenance_status`, `Link_modules_installed`, `Link_n_mod` and 10 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Link_committable: + description: whether flow is gated by an on/off status decision + dims: [link] + dtype: bool + Link_min_up_time: + description: least snapshots a link stays on once started + dims: [scenario, link] + dtype: int + Link_min_down_time: + description: least snapshots a link stays off once stopped + dims: [scenario, link] + dtype: int + Link_status_initial: + description: one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, link] + dtype: int + Link_must_stay_up: + description: >- + true while the up time a link brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, link] + dtype: bool + Link_must_stay_down: + description: >- + true while the down time a link brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, link] + dtype: bool + Link_start_up_cost: + description: cost of one start + dims: [scenario, link] + Link_shut_down_cost: + description: cost of one stop + dims: [scenario, link] + Link_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, link] + Link_big_m: + description: a bound safely above any feasible flow — the build cap at full availability, data prep + dims: [scenario, link] + +variables: + Link_status: + description: >- + `Link-status` — how much of a committable link is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_start_up: + description: "`Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + Link_shut_down: + description: "`Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Link_p_nom: { dims: [scenario, link] } + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_p_min_pu: { dims: [scenario, snapshot, link] } + Link_p_max_pu: { dims: [scenario, snapshot, link] } + Link_p_nom_mod: { dims: [link] } + Link_modules_installed: { dims: [scenario, link] } + Link_p_min_pu_nonneg: { dims: [link], dtype: bool } + Link_maintenance_pu: { dims: [scenario, link] } + period_weight_objective: { dims: [period] } + Link_active: { dims: [snapshot, link], dtype: bool } + variables: + Link_p: { dims: [scenario, snapshot, link] } + Link_n_mod: { dims: [link], domain: integer } + Link_maintenance_capacity: { dims: [scenario, snapshot, link] } + Link_maintenance_status: { dims: [scenario, snapshot, link] } + Link_p_nom_ext: { dims: [link] } + expressions: + scenario_opex: { dims: [scenario], term: Link_commitment_opex } + +expressions: + Link_previous_status: + description: >- + the commitment state a link carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial } + otherwise: shift(Link_status, along=snapshot, offset=1) + Link_commitment_opex: + expression: >- + sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + +constraints: + Link_com_p_lower: + description: "`Link-com-p-lower` — a committed link flows at least its minimum; off, at least nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_p_upper: + description: "`Link-com-p-upper` — a committed link flows at most what is available; off, at most nothing" + dims: [scenario, snapshot, link] + where: Link_committable AND not Link_p_nom_extendable AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_transition_start_up: + description: "`Link-com-transition-start-up` — turning on is a start, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_start_up >= Link_status - Link_previous_status + Link_com_transition_shut_down: + description: "`Link-com-transition-shut-down` — turning off is a stop, counted against the state the link carried into the snapshot" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_active + expression: Link_shut_down >= Link_previous_status - Link_status + Link_com_up_time: + description: >- + `Link-com-up-time` — a link started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_up_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_start_up, along=snapshot, window=Link_min_up_time) <= Link_status + Link_com_down_time: + description: >- + `Link-com-down-time` — a link stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, link] + where: Link_committable AND Link_min_down_time > 0 AND position(snapshot) > 0 AND Link_active + expression: sum_back(Link_shut_down, along=snapshot, window=Link_min_down_time) <= 1 - Link_status + Link_com_status_must_stay_up: + description: "`Link-com-status-min_up_time_must_stay_up` — a link still serving the up time it brought in stays on" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_up AND Link_active + expression: Link_status == 1 + Link_com_status_must_stay_down: + description: >- + `Link-com-status-min_down_time_must_stay_up` — a link still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, link] + where: Link_committable AND Link_must_stay_down AND Link_active + expression: Link_status == 0 + Link_com_ext_p_upper_cap: + description: >- + `Link-com-ext-p-upper-cap` — a committed extendable link flows + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_p_max_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + Link_com_ext_p_upper_big_m: + description: "`Link-com-ext-p-upper-bigM` — off, a link flows nothing; on, the big M is no bound" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p <= Link_big_m * Link_status + Link_com_ext_p_lower: + description: >- + `Link-com-ext-p-lower` — a committed extendable link flows at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: >- + Link_p >= + Link_p_min_pu * (Link_p_nom_ext - Link_maintenance_pu * Link_maintenance_capacity) + + Link_big_m * Link_status - Link_big_m + Link_com_ext_p_lower_nonneg: + description: >- + `Link-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, flow is also plainly non-negative, a row the big-M lower + cannot assert while the link is off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable + AND Link_p_min_pu_nonneg AND NOT (Link_p_nom_mod > 0) AND Link_active + expression: Link_p >= 0 + Link_com_mod_p_lower: + description: >- + `Link-com-mod-p-lower` — a committed modular link flows at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p >= Link_p_min_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_com_mod_p_upper: + description: >- + `Link-com-mod-p-upper` — a committed modular link flows at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_p <= Link_p_max_pu * Link_p_nom_mod * (Link_status - Link_maintenance_pu * Link_maintenance_status) + Link_status_p_fixed_upper: + description: >- + `Link-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_status <= Link_modules_installed + Link_start_up_p_fixed_upper: + description: >- + `Link-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_start_up <= Link_modules_installed + Link_shut_down_p_fixed_upper: + description: >- + `Link-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, link] + where: Link_committable AND NOT (Link_p_nom_extendable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_shut_down <= Link_modules_installed + Link_status_p_nom_variable_upper: + description: "`Link-status-p_nom-variable-upper` — a modular link is on only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_status <= Link_n_mod + Link_start_up_p_nom_variable_upper: + description: "`Link-start_up-p_nom-variable-upper` — a modular link starts only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_start_up <= Link_n_mod + Link_shut_down_p_nom_variable_upper: + description: "`Link-shut_down-p_nom-variable-upper` — a modular link stops only where a module is built" + dims: [scenario, snapshot, link] + where: Link_committable AND Link_p_nom_extendable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_shut_down <= Link_n_mod +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{L}`$ | index $`l`$ — `link` — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | +| $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays on once started | +| $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays off once stopped | +| $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\Xi \times \mathcal{L}`$ — cost of one start | +| $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\Xi \times \mathcal{L}`$ — cost of one stop | +| $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$ — a bound safely above any feasible flow — the build cap at full availability, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-status` — how much of a committable link is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is | +| $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$, data another file declares | +| $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, data another file declares | +| $`\overline{\mathrm{f}}`$ | `Link_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, data another file declares | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$, data another file declares | +| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$, data another file declares | +| $`f`$ | `Link_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`N^{f}`$ | `Link_n_mod` over $`\mathcal{L}`$ | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Link_commitment_opex` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the commitment state a link carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\mathit{Link\_commitment\_opex}`$ | `Link_commitment_opex` over $`\Xi`$ | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +#### Subject to + +**`Link_com_p_lower`** + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_p_upper`** + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_transition_start_up`** + +```math +\mathit{up}^{f}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_transition_shut_down`** + +```math +\mathit{dn}^{f}_{\xi,t,l} \ge \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_up_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{f}} \mathit{up}^{f}_{\xi,t',l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{UT}^{f}_{\xi,l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_down_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{f}} \mathit{dn}^{f}_{\xi,t',l} \le 1 - u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{DT}^{f}_{\xi,l} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_status_must_stay_up`** + +```math +u^{f}_{\xi,t,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{hold}^{f}_{\xi,t,l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_status_must_stay_down`** + +```math +u^{f}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{rest}^{f}_{\xi,t,l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_ext_p_upper_cap`** + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_ext_p_upper_big_m`** + +```math +f_{\xi,t,l} \le \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_ext_p_lower`** + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \left( F_{l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,\mathrm{nom}}_{\xi,t,l} \right) + \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} - \mathrm{M}^{f}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_ext_p_lower_nonneg`** + +```math +f_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{nonneg}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_mod_p_lower`** + +```math +f_{\xi,t,l} \ge \underline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_com_mod_p_upper`** + +```math +f_{\xi,t,l} \le \overline{\mathrm{f}}_{\xi,t,l} \cdot \mathrm{f}^{\mathrm{mod}}_{l} \cdot \left( u^{f}_{\xi,t,l} - \gamma^{f}_{\xi,l} \cdot \mu^{f,u}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_status_p_fixed_upper`** + +```math +u^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_start_up_p_fixed_upper`** + +```math +\mathit{up}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_shut_down_p_fixed_upper`** + +```math +\mathit{dn}^{f}_{\xi,t,l} \le \mathrm{N}^{f,\mathrm{fix}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_status_p_nom_variable_upper`** + +```math +u^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_start_up_p_nom_variable_upper`** + +```math +\mathit{up}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_shut_down_p_nom_variable_upper`** + +```math +\mathit{dn}^{f}_{\xi,t,l} \le N^{f}_{l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +#### Definitions + +**`Link_previous_status`** + +```math +\overleftarrow{u}^{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{\xi,l} & \text{if } \mathrm{pos}(t) = 0 \\ u^{f}_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +**`Link_commitment_opex`** + +```math +\mathit{Link\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} u^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{on}}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{up}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{up}}_{\xi,l} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} \mathit{dn}^{f}_{\xi,t,l} \cdot \mathrm{c}^{f,\mathrm{dn}}_{\xi,l} \qquad \forall\, \xi \in \Xi +``` + +#### Variable domains + +**`Link_status`** + +```math +u^{f}_{\xi,t,l} \ge 0, u^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_start_up`** + +```math +\mathit{up}^{f}_{\xi,t,l} \ge 0, \mathit{up}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_shut_down`** + +```math +\mathit{dn}^{f}_{\xi,t,l} \ge 0, \mathit{dn}^{f}_{\xi,t,l} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + diff --git a/docs/examples/pypsa/link_maintenance.md b/docs/examples/pypsa/link_maintenance.md new file mode 100644 index 00000000..d8b5ed3d --- /dev/null +++ b/docs/examples/pypsa/link_maintenance.md @@ -0,0 +1,418 @@ + + +# Links, the maintenance + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`, the maintenance rows. It reads `Link_active`, `Link_committable`, `Link_p_nom_ext`, `Link_p_nom_extendable`, `Link_p_nom_max`, `Link_p_nom_min` and 3 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + +relations: + Link_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a link that is + not maintainable + key: { scenario: scenario, link: link, start: snapshot, covered: snapshot } + +parameters: + Link_maintainable: + description: >- + whether a link must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [link] + dtype: bool + Link_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, link] + Link_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, link] + dtype: int + Link_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the link is not + maintainable. No row reads it: data prep turns it into + `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, link] + Link_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the link does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, link] + dtype: bool + +variables: + Link_maintenance: + description: >- + `Link-maintenance` — whether a maintainable link is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + bounds: + lower: 0 + upper: 1 + Link_maintenance_start: + description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + absence: zero + domain: binary + Link_maintenance_capacity: + description: >- + `Link-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_p_nom_extendable + AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + absence: zero + bounds: + lower: 0 + Link_maintenance_status: + description: >- + `Link-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, link] + where: >- + Link_maintainable AND Link_committable + AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_committable: { dims: [link], dtype: bool } + Link_p_nom_mod: { dims: [link] } + Link_active: { dims: [snapshot, link], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + Link_p_nom_min: { dims: [scenario, link] } + Link_p_nom_max: { dims: [scenario, link] } + variables: + Link_status: { dims: [scenario, snapshot, link], domain: integer } + Link_p_nom_ext: { dims: [link] } + +constraints: + Link_maint_event_count: + description: "`Link-maint-event-count` — a maintainable link holds its number of maintenance events over the horizon" + dims: [scenario, link] + where: Link_maintainable + expression: sum(Link_maintenance_start, over=snapshot) == Link_maintenance_events + Link_maint_window: + description: >- + `Link-maint-window` — a link is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active + expression: Link_maintenance == sum(Link_maintenance_start, by=Link_maintenance_cover, over=start, into=covered) + Link_maint_start_horizon: + description: "`Link-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_active AND Link_maintenance_start_blocked + expression: Link_maintenance_start == 0 + Link_maintcap_upper: + description: >- + `Link-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_ext - Link_p_nom_min * (1 - Link_maintenance) + Link_maintcap_upper_nommax: + description: "`Link-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity <= Link_p_nom_max * Link_maintenance + Link_maintcap_lower_nommax: + description: "`Link-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active + expression: Link_maintenance_capacity >= Link_p_nom_ext - Link_p_nom_max * (1 - Link_maintenance) + Link_maintcap_lower_nommin: + description: "`Link-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active AND Link_p_nom_min > 0 + expression: Link_maintenance_capacity >= Link_p_nom_min * Link_maintenance + Link_maint_status_le_status: + description: "`Link-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_status_le_maint: + description: "`Link-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status <= Link_maintenance + Link_maint_status_lb: + description: "`Link-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_active + expression: Link_maintenance_status >= Link_status + Link_maintenance - 1 + Link_maint_modstatus_le_status: + description: "`Link-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_status + Link_maint_modstatus_le_maint: + description: >- + `Link-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status <= Link_p_nom_max / Link_p_nom_mod * Link_maintenance + Link_maint_modstatus_lb: + description: "`Link-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, link] + where: Link_maintainable AND Link_committable AND Link_p_nom_mod > 0 AND Link_active + expression: Link_maintenance_status >= Link_status - Link_p_nom_max / Link_p_nom_mod * (1 - Link_maintenance) + +assumptions: + Link_maintenance_events_positive: + holds: "Link_maintenance_events > 0" + where: "Link_maintainable" + description: >- + a maintainable link with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Link_maintenance_duration_positive: + holds: "Link_maintenance_duration > 0" + where: "Link_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Link_maintenance_duration_fits_the_horizon: + holds: "Link_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Link_maintenance_events_fit_the_horizon: + holds: "Link_maintenance_duration * Link_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Link_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Link_maintenance_build_cap_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Link_maintenance_module_count_is_finite: + holds: "Link_p_nom_max < inf" + where: "Link_maintainable AND Link_committable AND NOT Link_p_nom_extendable AND Link_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` with $`\mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | +| $`\mathcal{L}`$ | index $`l`$ — `link` with $`\mathrm{Link\_maintenance\_cover} \subseteq \Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{T}`$ — controllable connections, each from one bus to the buses it delivers to | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\Xi \times \mathcal{L}`$ — how many maintenance events the horizon holds | +| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$, data another file declares | +| $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ | + +#### Subject to + +**`Link_maint_event_count`** + +```math +\sum_{t \in \mathcal{T}} \mu^{f,\mathrm{up}}_{\xi,t,l} = \mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +**`Link_maint_window`** + +```math +\mu^{f}_{\xi,t,l} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ l,\ t',\ t \right) \in \mathrm{Link\_maintenance\_cover}} \mu^{f,\mathrm{up}}_{\xi,t',l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_start_horizon`** + +```math +\mu^{f,\mathrm{up}}_{\xi,t,l} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} \wedge \mathrm{blk}^{f}_{\xi,t,l} +``` + +**`Link_maintcap_upper`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le F_{l} - \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintcap_upper_nommax`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \le \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintcap_lower_nommax`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge F_{l} - \overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintcap_lower_nommin`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} \wedge \underline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} > 0 +``` + +**`Link_maint_status_le_status`** + +```math +\mu^{f,u}_{\xi,t,l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_status_le_maint`** + +```math +\mu^{f,u}_{\xi,t,l} \le \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_status_lb`** + +```math +\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} + \mu^{f}_{\xi,t,l} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_modstatus_le_status`** + +```math +\mu^{f,u}_{\xi,t,l} \le u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_modstatus_le_maint`** + +```math +\mu^{f,u}_{\xi,t,l} \le \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \mu^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maint_modstatus_lb`** + +```math +\mu^{f,u}_{\xi,t,l} \ge u^{f}_{\xi,t,l} - \frac{\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l}}{\mathrm{f}^{\mathrm{mod}}_{l}} \cdot \left( 1 - \mu^{f}_{\xi,t,l} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \wedge \mathrm{on}^{f}_{t,l} +``` + +#### Variable domains + +**`Link_maintenance`** + +```math +0 \le \mu^{f}_{\xi,t,l} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_start`** + +```math +\mu^{f,\mathrm{up}}_{\xi,t,l} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_capacity`** + +```math +\mu^{f,\mathrm{nom}}_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_maintenance_status`** + +```math +\mu^{f,u}_{\xi,t,l} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \left( \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +#### Assumptions + +**`Link_maintenance_events_positive`** + +```math +\mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} > 0 \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +**`Link_maintenance_duration_positive`** + +```math +\tau^{f,\mathrm{mnt}}_{\xi,l} > 0 \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +**`Link_maintenance_duration_fits_the_horizon`** + +```math +\tau^{f,\mathrm{mnt}}_{\xi,l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +**`Link_maintenance_events_fit_the_horizon`** + +```math +\tau^{f,\mathrm{mnt}}_{\xi,l} \cdot \mathrm{n}^{f,\mathrm{mnt}}_{\xi,l} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} +``` + +**`Link_maintenance_build_cap_is_finite`** + +```math +\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} < \infty \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} +``` + +**`Link_maintenance_module_count_is_finite`** + +```math +\overline{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} < \infty \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} \,:\, \mathrm{mnt}^{f}_{l} \wedge \mathrm{com}^{f}_{l} \wedge \neg \mathrm{ext}^{f}_{l} \wedge \mathrm{f}^{\mathrm{mod}}_{l} > 0 +``` + diff --git a/docs/examples/pypsa/link_ramping.md b/docs/examples/pypsa/link_ramping.md new file mode 100644 index 00000000..0b55db30 --- /dev/null +++ b/docs/examples/pypsa/link_ramping.md @@ -0,0 +1,390 @@ + + +# Links, the ramping + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`, the ramping rows. It reads `Link_active`, `Link_big_m`, `Link_committable`, `Link_p`, `Link_p_nom_committed`, `Link_p_nom_effective` and 8 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + link: + description: controllable connections, each from one bus to the buses it delivers to + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Link_ramp_limit_up: + description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] + Link_ramp_limit_down: + description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, link] + Link_ramp_limit_start_up: + description: most flow in the snapshot a link starts, per unit of nominal power + dims: [scenario, link] + Link_ramp_limit_shut_down: + description: most flow in the snapshot before a link stops, per unit of nominal power + dims: [scenario, link] + Link_p_init: + description: >- + the flow a link brought into the horizon — PyPSA's `p_init`, read + only where the link came in running; no value means it is unknown, so + the link carries no ramp row at the first snapshot + dims: [scenario, link] + +given: + parameters: + Link_p_nom_extendable: { dims: [link], dtype: bool } + Link_committable: { dims: [link], dtype: bool } + Link_status_initial: { dims: [scenario, link], dtype: int } + Link_p_nom_mod: { dims: [link] } + Link_big_m: { dims: [scenario, link] } + Link_active: { dims: [snapshot, link], dtype: bool } + variables: + Link_p: { dims: [scenario, snapshot, link] } + Link_status: { dims: [scenario, snapshot, link], domain: integer } + Link_start_up: { dims: [scenario, snapshot, link], domain: integer } + Link_shut_down: { dims: [scenario, snapshot, link], domain: integer } + Link_p_nom_ext: { dims: [link] } + expressions: + Link_p_nom_effective: { dims: [scenario, link] } + Link_previous_status: { dims: [scenario, snapshot, link] } + Link_p_nom_committed: { dims: [scenario, link] } + +expressions: + Link_previous_p: + description: >- + the flow a link carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, link] + cases: + opening: { when: "position(snapshot) == 0", expression: Link_status_initial * Link_p_init } + otherwise: shift(Link_p, along=snapshot, offset=1) + Link_ramp_up_rate: + description: >- + the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, link] + cases: + given: { when: Link_ramp_limit_up, expression: Link_ramp_limit_up } + otherwise: 1 + Link_ramp_down_rate: + description: >- + the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, link] + cases: + given: { when: Link_ramp_limit_down, expression: Link_ramp_limit_down } + otherwise: 1 + Link_start_up_rate: + description: >- + the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, link] + cases: + given: { when: Link_ramp_limit_start_up, expression: Link_ramp_limit_start_up } + otherwise: 1 + Link_shut_down_rate: + description: >- + the shut-down ramp a link's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, link] + cases: + given: { when: Link_ramp_limit_shut_down, expression: Link_ramp_limit_shut_down } + otherwise: 1 + Link_ramp_up_allowance: + description: >- + how far a link may raise flow between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_up_rate * Link_p_nom_committed * Link_previous_status + + Link_start_up_rate * Link_p_nom_committed + * (Link_status - Link_previous_status) + otherwise: Link_ramp_up_rate * Link_p_nom_effective + Link_ramp_down_allowance: + description: >- + how far a link may lower flow between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, link] + cases: + committed: + when: Link_committable + expression: >- + Link_ramp_down_rate * Link_p_nom_committed * Link_status + + Link_shut_down_rate * Link_p_nom_committed + * (Link_previous_status - Link_status) + otherwise: Link_ramp_down_rate * Link_p_nom_effective + +constraints: + Link_p_ramp_limit_up_run_big_m: + description: >- + `Link-p-ramp_limit_up-run-bigM` — a committed extendable link + raises flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_ramp_up_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_previous_status + Link_p_ramp_limit_up_start_big_m: + description: >- + `Link-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable link ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_p - Link_previous_p <= + Link_start_up_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_start_up + Link_p_ramp_limit_down_run_big_m: + description: >- + `Link-p-ramp_limit_down-run-bigM` — a committed extendable link + lowers flow no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_ramp_down_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_status + Link_p_ramp_limit_down_shut_big_m: + description: >- + `Link-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable link ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, link] + where: >- + Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0) + AND (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: >- + Link_previous_p - Link_p <= + Link_shut_down_rate * Link_p_nom_ext + + Link_big_m - Link_big_m * Link_shut_down + Link_p_ramp_limit_up: + description: >- + `Link-p-ramp_limit_up` — a link raises flow no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A link that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead + dims: [scenario, snapshot, link] + where: >- + (Link_ramp_limit_up OR Link_ramp_limit_start_up) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: Link_p - Link_previous_p <= Link_ramp_up_allowance + Link_p_ramp_limit_down: + description: >- + `Link-p-ramp_limit_down` — a link lowers flow no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A link that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the flow it brought in, and no link carries one at + the start of a later investment period — nor does any link a big M releases instead + dims: [scenario, snapshot, link] + where: >- + (Link_ramp_limit_down OR Link_ramp_limit_shut_down) + AND NOT (Link_committable AND Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Link_status_initial == 0 OR Link_p_init))) + AND Link_active + expression: Link_previous_p - Link_p <= Link_ramp_down_allowance + +assumptions: + Link_came_in_running_unless_committable: + holds: "Link_status_initial == 1" + where: "NOT Link_committable AND (Link_ramp_limit_up OR Link_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a link that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the link there at zero, or at its + start-up ramp where another link of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable link and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{L}`$ | index $`l`$ — `link` — controllable connections, each from one bus to the buses it delivers to | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | +| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$ — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$, data another file declares | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$, data another file declares | +| $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$, data another file declares | +| $`f`$ | `Link_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ | +| $`F`$ | `Link_p_nom_ext` over $`\mathcal{L}`$ | +| $`\widetilde{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_effective` over $`\Xi \times \mathcal{L}`$, an expression another file defines | +| $`\overleftarrow{u}^{f}`$ | `Link_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, an expression another file defines | +| $`\widehat{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_committed` over $`\Xi \times \mathcal{L}`$, an expression another file defines | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{f}`$ | `Link_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the flow a link carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | +| $`\widetilde{\mathrm{ru}}^{f}`$ | `Link_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{f}`$ | `Link_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — the ramp limit a link's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{f,\mathrm{up}}`$ | `Link_start_up_rate` over $`\Xi \times \mathcal{L}`$ — the start-up ramp a link's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}`$ | `Link_shut_down_rate` over $`\Xi \times \mathcal{L}`$ — the shut-down ramp a link's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\Delta^{f,+}`$ | `Link_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may raise flow between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{f,-}`$ | `Link_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — how far a link may lower flow between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + +#### Subject to + +**`Link_p_ramp_limit_up_run_big_m`** + +```math +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_p_ramp_limit_up_start_big_m`** + +```math +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{up}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_p_ramp_limit_down_run_big_m`** + +```math +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot u^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_p_ramp_limit_down_shut_big_m`** + +```math +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot F_{l} + \mathrm{M}^{f}_{\xi,l} - \mathrm{M}^{f}_{\xi,l} \cdot \mathit{dn}^{f}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \wedge \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_p_ramp_limit_up`** + +```math +f_{\xi,t,l} - \overleftarrow{f}_{\xi,t,l} \le \Delta^{f,+}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +**`Link_p_ramp_limit_down`** + +```math +\overleftarrow{f}_{\xi,t,l} - f_{\xi,t,l} \le \Delta^{f,-}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \left( \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{f}_{l} \wedge \mathrm{ext}^{f}_{l} \wedge \neg \left( \mathrm{f}^{\mathrm{mod}}_{l} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{f,0}_{\xi,l} = 0 \vee \mathrm{f}^{0}_{\xi,l} \text{ is defined} \right) \right) \wedge \mathrm{on}^{f}_{t,l} +``` + +#### Definitions + +**`Link_previous_p`** + +```math +\overleftarrow{f}_{\xi,t,l} = \begin{cases} \mathrm{u}^{f,0}_{\xi,l} \cdot \mathrm{f}^{0}_{\xi,l} & \text{if } \mathrm{pos}(t) = 0 \\ f_{\xi,t - 1,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +**`Link_ramp_up_rate`** + +```math +\widetilde{\mathrm{ru}}^{f}_{\xi,t,l} = \begin{cases} \mathrm{ru}^{f}_{\xi,t,l} & \text{if } \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +**`Link_ramp_down_rate`** + +```math +\widetilde{\mathrm{rd}}^{f}_{\xi,t,l} = \begin{cases} \mathrm{rd}^{f}_{\xi,t,l} & \text{if } \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +**`Link_start_up_rate`** + +```math +\widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} = \begin{cases} \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} & \text{if } \mathrm{ru}^{f,\mathrm{up}}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Link_shut_down_rate`** + +```math +\widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} = \begin{cases} \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} & \text{if } \mathrm{rd}^{f,\mathrm{dn}}_{\xi,l} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ l \in \mathcal{L} +``` + +**`Link_ramp_up_allowance`** + +```math +\Delta^{f,+}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \overleftarrow{u}^{f}_{\xi,t,l} + \widetilde{\mathrm{ru}}^{f,\mathrm{up}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( u^{f}_{\xi,t,l} - \overleftarrow{u}^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{ru}}^{f}_{\xi,t,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +**`Link_ramp_down_allowance`** + +```math +\Delta^{f,-}_{\xi,t,l} = \begin{cases} \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot u^{f}_{\xi,t,l} + \widetilde{\mathrm{rd}}^{f,\mathrm{dn}}_{\xi,l} \cdot \widehat{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} \cdot \left( \overleftarrow{u}^{f}_{\xi,t,l} - u^{f}_{\xi,t,l} \right) & \text{if } \mathrm{com}^{f}_{l} \\ \widetilde{\mathrm{rd}}^{f}_{\xi,t,l} \cdot \widetilde{\mathrm{f}}^{\mathrm{nom}}_{\xi,l} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} +``` + +#### Assumptions + +**`Link_came_in_running_unless_committable`** + +```math +\mathrm{u}^{f,0}_{\xi,l} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ l \in \mathcal{L} \,:\, \neg \mathrm{com}^{f}_{l} \wedge \left( \mathrm{ru}^{f}_{\xi,t,l} \text{ is defined} \vee \mathrm{rd}^{f}_{\xi,t,l} \text{ is defined} \right) +``` + diff --git a/docs/examples/pypsa/load.md b/docs/examples/pypsa/load.md new file mode 100644 index 00000000..7b61a4a7 --- /dev/null +++ b/docs/examples/pypsa/load.md @@ -0,0 +1,107 @@ + + +# Loads + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Load`. It adds a term to `Bus_injection`. + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + load: + description: demands, each on one bus + +relations: + Load_bus: + description: the bus a load sits on + key: load + values: bus + +parameters: + Load_p_set: + description: demand + dims: [scenario, snapshot, load] + Load_sign: + description: >- + the sign a load's demand enters its bus's balance with — PyPSA's + `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA + refuses one that differs by scenario (`consistency.py:1187`) + dims: [load] + Load_active: + description: >- + whether a load stands in the model — PyPSA's `active`. A load has no + build year and no lifetime, so the flag holds in every snapshot. PyPSA + refuses one that differs by scenario (`consistency.py:1195`) + dims: [load] + dtype: bool + +given: + expressions: + Bus_injection: { dims: [scenario, snapshot, bus], term: Load_injection } + +expressions: + Load_demand: + description: >- + what a load draws from its bus's balance — its demand times its sign + where it is active, nothing where it is not, since PyPSA drops an + inactive load from the balance (`constraints.py:1537-1538`) + dims: [scenario, snapshot, load] + cases: + active: { when: Load_active, expression: Load_sign * Load_p_set } + otherwise: 0 + Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | +| $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | +| $`\mathrm{on}^{\mathrm{load}}`$ | `Load_active` over $`\mathcal{D}`$ — whether a load stands in the model — PyPSA's `active`. A load has no build year and no lifetime, so the flag holds in every snapshot. PyPSA refuses one that differs by scenario (`consistency.py:1195`) | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Load_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\check{\mathrm{load}}`$ | `Load_demand` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — what a load draws from its bus's balance — its demand times its sign where it is active, nothing where it is not, since PyPSA drops an inactive load from the balance (`constraints.py:1537-1538`) | +| $`\mathrm{Load\_injection}`$ | `Load_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +#### Definitions + +**`Load_demand`** + +```math +\check{\mathrm{load}}_{\xi,t,d} = \begin{cases} \mathrm{sgn}^{\mathrm{load}}_{d} \cdot \mathrm{load}_{\xi,t,d} & \text{if } \mathrm{on}^{\mathrm{load}}_{d} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ d \in \mathcal{D} +``` + +**`Load_injection`** + +```math +\mathrm{Load\_injection}_{\xi,t,n} = \sum_{d \in \mathcal{D} \,:\, \mathrm{Load\_bus}(d) = n} \check{\mathrm{load}}_{\xi,t,d} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + diff --git a/docs/examples/pypsa/network.md b/docs/examples/pypsa/network.md new file mode 100644 index 00000000..49726bbd --- /dev/null +++ b/docs/examples/pypsa/network.md @@ -0,0 +1,68 @@ + + +# The network + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the buses and the balance at each of them, which reads `Bus_injection`. It reads `Bus_injection` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + +given: + expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side + +constraints: + Bus_nodal_balance: + description: >- + `Bus-nodal_balance` — what is generated at a bus, storage dispatch and + stores included, less what the links take away, plus what arrives over + them after losses and any delay at every port they deliver to, each + process port drawing or delivering at its own rate and each passive branch + carrying its flow, meets the load there, less half of every incident + line's and transformer's loss — PyPSA dissipates a branch's loss half at + either end. Each generator, storage unit, store and load term enters + with its component's `sign` (`constraints.py:1428-1429`, `:1538`), and + an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that + carries load, and this file does not yet. + dims: [scenario, snapshot, bus] + expression: Bus_injection == 0 +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` — network nodes | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression another file defines — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | + +#### Subject to + +**`Bus_nodal_balance`** + +```math +\mathit{Bus\_injection}_{\xi,t,n} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + diff --git a/docs/examples/pypsa/power_flow.md b/docs/examples/pypsa/power_flow.md new file mode 100644 index 00000000..a7cc55df --- /dev/null +++ b/docs/examples/pypsa/power_flow.md @@ -0,0 +1,63 @@ + + +# Power flow + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: Kirchhoff's voltage law around each cycle, which reads `Cycle_angle_sum`. It reads `Cycle_angle_sum` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + +given: + expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift + +constraints: + Kirchhoff_Voltage_Law: + description: >- + `Kirchhoff-Voltage-Law` — around every independent cycle the + impedance-weighted flows sum to nothing, which is what makes the linear + power flow physical rather than transport. A transformer's flow weighs its + effective reactance, and its phase shift enters the cycle sum too: a + constant where the shift is fixed, or the shift decision times its cycle + weight where the shift is a phase-shifting transformer's to choose + dims: [scenario, snapshot, cycle] + expression: Cycle_angle_sum == 0 +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression another file defines — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | + +#### Subject to + +**`Kirchhoff_Voltage_Law`** + +```math +\mathit{Cycle\_angle\_sum}_{\xi,t,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + diff --git a/docs/examples/pypsa/process.md b/docs/examples/pypsa/process.md new file mode 100644 index 00000000..823a5055 --- /dev/null +++ b/docs/examples/pypsa/process.md @@ -0,0 +1,510 @@ + + +# Processes + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`. It adds a term to `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Process_committable`, `Process_maintenance`, `Process_maintenance_capacity`, `Process_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + process_output: + description: >- + a process's ports, one label per port a process declares — PyPSA's + `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number + of ports is one term in the balance, data prep + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Process_carrier: + description: the carrier a process converts from + key: process + values: carrier + Process_output_process: + description: the process a port belongs to + key: process_output + values: process + Process_output_bus: + description: >- + the bus a port draws from or delivers to — PyPSA's `bus0`, `bus1`, … + columns. A process of three ports is three labels here rather than a + third relation, so the file states any number of them + key: process_output + values: bus + +parameters: + Process_p_nom: + description: nominal internal power + dims: [scenario, process] + Process_p_nom_extendable: + description: whether the nominal internal power is a decision + dims: [process] + dtype: bool + Process_p_min_pu: + description: least internal power, per unit of nominal power — negative for a process that runs both ways + dims: [scenario, snapshot, process] + Process_p_max_pu: + description: most internal power, per unit of nominal power + dims: [scenario, snapshot, process] + Process_rate: + description: >- + the energy a port draws or delivers per unit of internal power, PyPSA's + `rate0`, `rate1`, … read long — negative where the port withdraws, + positive where it injects; a link is a process whose `bus0` rate is minus + one and whose output rates are its efficiencies + dims: [scenario, process_output] + Process_output_delay: + description: >- + snapshots a port's transfer lags its process's internal power — PyPSA's + `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` + units, which the file states as whole snapshots; zero for a port that + transfers at once. Each scenario takes its own, as a link's + dims: [scenario, process_output] + dtype: int + Process_output_cyclic_delay: + description: >- + whether a delayed port's transfer wraps from the end of its investment + period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, + the energy still in transit at each period's first snapshots is lost. + Each scenario takes its own, as the delay + dims: [scenario, process_output] + dtype: bool + Process_marginal_cost: + description: cost of one unit of internal power + dims: [scenario, snapshot, process] + Process_marginal_cost_quadratic: + description: cost of the square of one unit of internal power + dims: [scenario, snapshot, process] + Process_p_set: + description: a given internal power schedule; a process without one has no row here + dims: [scenario, snapshot, process] + Process_p_nom_min: + description: least nominal power an extendable process may be built at + dims: [scenario, process] + Process_p_nom_max: + description: most nominal power an extendable process may be built at + dims: [scenario, process] + Process_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, process] + Process_p_nom_set: + description: a given nominal power for an extendable process; one without a value has no row here + dims: [scenario, process] + Process_p_nom_mod: + description: the module size a build comes in whole numbers of; no value means the build is continuous + dims: [process] + Process_modules_installed: + description: >- + how many whole modules a committable build has in place: `Process_p_nom + / Process_p_nom_mod` where a fixed build is modular, one where it is + not, data prep. PyPSA refuses a fixed modular build whose nominal power + is not a whole number of modules + dims: [scenario, process] + Process_p_min_pu_nonneg: + description: >- + true where none of the process's own minimums-per-unit is negative — + PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep + dims: [process] + dtype: bool + Process_active: + description: whether a process stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, process] + dtype: bool + Process_capital_weight: + description: the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [process] + Process_first_active: + description: >- + one in the first period a process stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a process + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, process] + Process_tech_capacity_weight: + description: >- + one where the process is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, process] + +variables: + Process_p: + description: >- + `Process-p` — PyPSA's internal power `p`: a positive value drives every + port at its own rate, withdrawing where the rate is negative and injecting + where it is positive + dims: [scenario, snapshot, process] + where: Process_active + Process_n_mod: + description: "`Process-n_mod` — how many modules of an extendable modular build" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + domain: integer + bounds: + lower: 0 + Process_p_nom_ext: + description: >- + `Process-p_nom` — nominal internal power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [process] + where: Process_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Process_committable: { dims: [process], dtype: bool } + Process_maintenance_pu: { dims: [scenario, process] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + variables: + Process_maintenance: { dims: [scenario, snapshot, process] } + Process_maintenance_capacity: { dims: [scenario, snapshot, process] } + expressions: + tech_capacity_expansion: { dims: [global_constraint], term: Process_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Process_opex } + Carrier_additions: { dims: [period, carrier], term: Process_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Process_injection } + +expressions: + Process_p_nom_effective: + description: the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise + dims: [scenario, process] + cases: + extendable: { when: Process_p_nom_extendable, expression: Process_p_nom_ext } + otherwise: Process_p_nom + Process_p_nom_committed: + description: >- + the build a committed process's ramp rows are taken against — one module + where the build is extendable and modular, the given build otherwise + dims: [scenario, process] + cases: + modular_build: { when: Process_p_nom_extendable AND Process_p_nom_mod > 0, expression: Process_p_nom_mod } + otherwise: Process_p_nom + Process_output_arrival: + description: >- + what a process transfers at a port at a snapshot — its internal power + times the port's rate, delayed by the port's `delay` within its + investment period; where the port is `cyclic_delay` the delayed transfer + wraps from the period's end, and where it is not the energy still in + transit at the period's first snapshots is lost. A port that does not + delay (`delay` zero) transfers at once, cyclic or not + dims: [scenario, snapshot, process_output] + cases: + wrapping: + when: Process_output_cyclic_delay + expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) + otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) + Process_tech_capacity_expansion: + expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + Process_opex: + expression: >- + sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + Process_additions: + expression: >- + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + Process_injection: + expression: >- + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + +constraints: + Process_fix_p_lower: + description: "`Process-fix-p-lower` — a fixed process runs at least its minimum, negative for the other way" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) + Process_fix_p_upper: + description: "`Process-fix-p-upper` — a fixed process runs at most its nominal power" + dims: [scenario, snapshot, process] + where: not Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * (1 - Process_maintenance_pu * Process_maintenance) + Process_ext_p_lower: + description: "`Process-ext-p-lower` — an extendable process runs at least its minimum of the chosen build, negative for the other way" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p >= Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_ext_p_upper: + description: "`Process-ext-p-upper` — an extendable process runs at most the chosen build" + dims: [scenario, snapshot, process] + where: Process_p_nom_extendable AND not Process_committable AND Process_active + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_ext_p_nom_lower: + description: "`Process-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, process] + where: Process_p_nom_extendable + expression: Process_p_nom_ext >= Process_p_nom_min + Process_ext_p_nom_upper: + description: "`Process-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, process] + where: Process_p_nom_extendable AND Process_p_nom_max + expression: Process_p_nom_ext <= Process_p_nom_max + Process_p_nom_set: + description: "`Process-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, process] + where: Process_p_nom_extendable AND Process_p_nom_set + expression: Process_p_nom_ext == Process_p_nom_set + Process_p_nom_modularity: + description: "`Process-p_nom_modularity` — the chosen build is a whole number of modules" + dims: [process] + where: Process_p_nom_extendable AND Process_p_nom_mod > 0 + expression: Process_p_nom_ext == Process_p_nom_mod * Process_n_mod + Process_p_set: + description: "`Process-p_set` — internal power pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, process] + where: Process_p_set AND Process_active + expression: Process_p == Process_p_set + +assumptions: + Process_marginal_cost_quadratic_without_risk_preference: + holds: "Process_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Process_p_nom_ext) * Process_capital_cost) * Process_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I},\ \mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{R}`$ | index $`r`$ — `process_output` with $`\mathrm{Process\_output\_process}: \mathcal{R} \to \mathcal{J},\ \mathrm{Process\_output\_bus}: \mathcal{R} \to \mathcal{N}`$ — a process's ports, one label per port a process declares — PyPSA's `bus0`, `bus1`, … each carry a signed `rate`, so a process of any number of ports is one term in the balance, data prep | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Process\_carrier}: \mathcal{J} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\Xi \times \mathcal{J}`$ — nominal internal power | +| $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | +| $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — least internal power, per unit of nominal power — negative for a process that runs both ways | +| $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most internal power, per unit of nominal power | +| $`\alpha`$ | `Process_rate` over $`\Xi \times \mathcal{R}`$ — the energy a port draws or delivers per unit of internal power, PyPSA's `rate0`, `rate1`, … read long — negative where the port withdraws, positive where it injects; a link is a process whose `bus0` rate is minus one and whose output rates are its efficiencies | +| $`\mathrm{d}^{z}`$ | `Process_output_delay` over $`\Xi \times \mathcal{R}`$ — snapshots a port's transfer lags its process's internal power — PyPSA's `delay0`, `delay1`, … read long, in `snapshot_weightings.generators` units, which the file states as whole snapshots; zero for a port that transfers at once. Each scenario takes its own, as a link's | +| $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\Xi \times \mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | +| $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | +| $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | +| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | +| $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$ — least nominal power an extendable process may be built at | +| $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$ — most nominal power an extendable process may be built at | +| $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\Xi \times \mathcal{J}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\Xi \times \mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | +| $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | +| $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$ — whether a process stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{z}`$ | `Process_capital_weight` over $`\mathcal{J}`$ — the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a process that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`z`$ | `Process_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-p` — PyPSA's internal power `p`: a positive value drives every port at its own rate, withdrawing where the rate is negative and injecting where it is positive | +| $`N^{z}`$ | `Process_n_mod` over $`\mathcal{J}`$ — `Process-n_mod` — how many modules of an extendable modular build | +| $`Z`$ | `Process_p_nom_ext` over $`\mathcal{J}`$ — `Process-p_nom` — nominal internal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$, data another file declares | +| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Process_tech_capacity_expansion` to | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Process_opex` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Process_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Process_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\Xi \times \mathcal{J}`$ — the build a process's limits are taken against — the chosen one where it is extendable, the given one otherwise | +| $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\Xi \times \mathcal{J}`$ — the build a committed process's ramp rows are taken against — one module where the build is extendable and modular, the given build otherwise | +| $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power times the port's rate, delayed by the port's `delay` within its investment period; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | +| $`\mathit{Process\_tech\_capacity\_expansion}`$ | `Process_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{Process\_opex}`$ | `Process_opex` over $`\Xi`$ | +| $`\mathit{Process\_additions}`$ | `Process_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Process\_injection}`$ | `Process_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +$`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $`t \boxminus_{v}^{\mathrm{relation}(t)} k`$ is both at once. + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} +``` + +#### Subject to + +**`Process_fix_p_lower`** + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \gamma^{z}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_fix_p_upper`** + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \gamma^{z}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_ext_p_lower`** + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_ext_p_upper`** + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \neg \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_ext_p_nom_lower`** + +```math +Z_{j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} +``` + +**`Process_ext_p_nom_upper`** + +```math +Z_{j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \text{ is defined} +``` + +**`Process_p_nom_set`** + +```math +Z_{j} = \mathrm{z}^{\mathrm{nom,set}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{nom,set}}_{\xi,j} \text{ is defined} +``` + +**`Process_p_nom_modularity`** + +```math +Z_{j} = \mathrm{z}^{\mathrm{mod}}_{j} \cdot N^{z}_{j} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + +**`Process_p_set`** + +```math +z_{\xi,t,j} = \mathrm{z}^{\mathrm{set}}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{z}^{\mathrm{set}}_{\xi,t,j} \text{ is defined} \wedge \mathrm{on}^{z}_{t,j} +``` + +#### Definitions + +**`Process_p_nom_effective`** + +```math +\widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} = \begin{cases} Z_{j} & \text{if } \mathrm{ext}^{z}_{j} \\ \mathrm{z}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +``` + +**`Process_p_nom_committed`** + +```math +\widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} = \begin{cases} \mathrm{z}^{\mathrm{mod}}_{j} & \text{if } \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \\ \mathrm{z}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +``` + +**`Process_output_arrival`** + +```math +\overrightarrow{z}_{\xi,t,r} = \begin{cases} z_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{if } \mathrm{cyc}^{z}_{\xi,r} \\ z_{\xi,t \boxminus_{0}^{\mathrm{snapshot\_period}(t)} \mathrm{d}^{z},\mathrm{Process\_output\_process}(r)} \cdot \alpha_{\xi,r} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ r \in \mathcal{R} +``` + +**`Process_tech_capacity_expansion`** + +```math +\mathit{Process\_tech\_capacity\_expansion}_{g} = \sum_{j \in \mathcal{J}} Z_{j} \cdot \mathrm{m}^{z}_{g,j} \qquad \forall\, g \in \mathcal{G} +``` + +**`Process_opex`** + +```math +\mathit{Process\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +**`Process_additions`** + +```math +\mathit{Process\_additions}_{y,i} = \sum_{j \in \mathcal{J} \,:\, \mathrm{Process\_carrier}(j) = i} Z_{j} \cdot \mathrm{new}^{z}_{y,j} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`Process_injection`** + +```math +\mathit{Process\_injection}_{\xi,t,n} = \sum_{r \in \mathcal{R} \,:\, \mathrm{Process\_output\_bus}(r) = n} \overrightarrow{z}_{\xi,t,r} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Process_p`** + +```math +z_{\xi,t,j} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{on}^{z}_{t,j} +``` + +**`Process_n_mod`** + +```math +N^{z}_{j} \ge 0, N^{z}_{j} \in \mathbb{Z} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + +**`Process_p_nom_ext`** + +```math +Z_{j} \in \mathbb{R} \qquad \forall\, j \in \mathcal{J} \,:\, \mathrm{ext}^{z}_{j} +``` + +#### Assumptions + +**`Process_marginal_cost_quadratic_without_risk_preference`** + +```math +\mathrm{c}^{z,(2)}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \omega > 0 +``` + diff --git a/docs/examples/pypsa/process_commitment.md b/docs/examples/pypsa/process_commitment.md new file mode 100644 index 00000000..a64e95ea --- /dev/null +++ b/docs/examples/pypsa/process_commitment.md @@ -0,0 +1,491 @@ + + +# Processes, the commitment + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`, the commitment rows. It adds a term to `scenario_opex`. It reads `Process_active`, `Process_maintenance_capacity`, `Process_maintenance_pu`, `Process_maintenance_status`, `Process_modules_installed`, `Process_n_mod` and 10 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Process_committable: + description: whether internal power is gated by an on/off status decision + dims: [process] + dtype: bool + Process_min_up_time: + description: least snapshots a process stays on once started + dims: [scenario, process] + dtype: int + Process_min_down_time: + description: least snapshots a process stays off once stopped + dims: [scenario, process] + dtype: int + Process_status_initial: + description: one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep + dims: [scenario, process] + dtype: int + Process_must_stay_up: + description: >- + true while the up time a process brought into the horizon still binds — + data prep, since `position()` compares against a literal rather than a + parameter + dims: [scenario, snapshot, process] + dtype: bool + Process_must_stay_down: + description: >- + true while the down time a process brought into the horizon still binds — + PyPSA's `min_down_time - down_time_before` snapshots, where + `down_time_before > 0`, data prep for the same reason + dims: [scenario, snapshot, process] + dtype: bool + Process_start_up_cost: + description: cost of one start + dims: [scenario, process] + Process_shut_down_cost: + description: cost of one stop + dims: [scenario, process] + Process_stand_by_cost: + description: cost of one snapshot spent on + dims: [scenario, snapshot, process] + Process_big_m: + description: a bound safely above any feasible internal power — the build cap at full availability, data prep + dims: [scenario, process] + +variables: + Process_status: + description: >- + `Process-status` — how much of a committable process is on: an integer + the rows below cap at one, or at the module count where the build is + modular + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_start_up: + description: "`Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + Process_shut_down: + description: "`Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + domain: integer + bounds: + lower: 0 + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + Process_p_nom: { dims: [scenario, process] } + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_p_min_pu: { dims: [scenario, snapshot, process] } + Process_p_max_pu: { dims: [scenario, snapshot, process] } + Process_p_nom_mod: { dims: [process] } + Process_modules_installed: { dims: [scenario, process] } + Process_p_min_pu_nonneg: { dims: [process], dtype: bool } + Process_maintenance_pu: { dims: [scenario, process] } + period_weight_objective: { dims: [period] } + Process_active: { dims: [snapshot, process], dtype: bool } + variables: + Process_p: { dims: [scenario, snapshot, process] } + Process_n_mod: { dims: [process], domain: integer } + Process_maintenance_capacity: { dims: [scenario, snapshot, process] } + Process_maintenance_status: { dims: [scenario, snapshot, process] } + Process_p_nom_ext: { dims: [process] } + expressions: + scenario_opex: { dims: [scenario], term: Process_commitment_opex } + +expressions: + Process_previous_status: + description: >- + the commitment state a process carries into a snapshot — the state it + brought into the horizon at the first, the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial } + otherwise: shift(Process_status, along=snapshot, offset=1) + Process_commitment_opex: + expression: >- + sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) + +constraints: + Process_com_p_lower: + description: "`Process-com-p-lower` — a committed process runs at least its minimum; off, at least nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_p_upper: + description: "`Process-com-p-upper` — a committed process runs at most what is available; off, at most nothing" + dims: [scenario, snapshot, process] + where: Process_committable AND not Process_p_nom_extendable AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_transition_start_up: + description: "`Process-com-transition-start-up` — turning on is a start, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_start_up >= Process_status - Process_previous_status + Process_com_transition_shut_down: + description: "`Process-com-transition-shut-down` — turning off is a stop, counted against the state the process carried into the snapshot" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_active + expression: Process_shut_down >= Process_previous_status - Process_status + Process_com_up_time: + description: >- + `Process-com-up-time` — a process started within its own minimum up time + is still on. The first snapshot's share of the window is the brought-in + up time's, which the must-stay-up mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_up_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_start_up, along=snapshot, window=Process_min_up_time) <= Process_status + Process_com_down_time: + description: >- + `Process-com-down-time` — a process stopped within its own minimum down + time is still off. The first snapshot's share of the window is the + brought-in down time's, which the must-stay-down mask carries + dims: [scenario, snapshot, process] + where: Process_committable AND Process_min_down_time > 0 AND position(snapshot) > 0 AND Process_active + expression: sum_back(Process_shut_down, along=snapshot, window=Process_min_down_time) <= 1 - Process_status + Process_com_status_must_stay_up: + description: "`Process-com-status-min_up_time_must_stay_up` — a process still serving the up time it brought in stays on" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_up AND Process_active + expression: Process_status == 1 + Process_com_status_must_stay_down: + description: >- + `Process-com-status-min_down_time_must_stay_up` — a process still serving + the down time it brought in stays off; PyPSA names the row `_must_stay_up` + dims: [scenario, snapshot, process] + where: Process_committable AND Process_must_stay_down AND Process_active + expression: Process_status == 0 + Process_com_ext_p_upper_cap: + description: >- + `Process-com-ext-p-upper-cap` — a committed extendable process runs + at most what is available of the chosen build, whatever its status + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_p_max_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + Process_com_ext_p_upper_big_m: + description: "`Process-com-ext-p-upper-bigM` — off, a process does not run; on, the big M is no bound" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p <= Process_big_m * Process_status + Process_com_ext_p_lower: + description: >- + `Process-com-ext-p-lower` — a committed extendable process runs at + least its minimum of the chosen build; off, the big M releases the row + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: >- + Process_p >= + Process_p_min_pu * (Process_p_nom_ext - Process_maintenance_pu * Process_maintenance_capacity) + + Process_big_m * Process_status - Process_big_m + Process_com_ext_p_lower_nonneg: + description: >- + `Process-com-ext-p-lower-nonneg` — where no minimum-per-unit is + negative, internal power is also plainly non-negative, a row the big-M lower + cannot assert while the process is off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable + AND Process_p_min_pu_nonneg AND NOT (Process_p_nom_mod > 0) AND Process_active + expression: Process_p >= 0 + Process_com_mod_p_lower: + description: >- + `Process-com-mod-p-lower` — a committed modular process runs at least + its minimum of one module, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p >= Process_p_min_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_com_mod_p_upper: + description: >- + `Process-com-mod-p-upper` — a committed modular process runs at most + one module's share, whether the build is fixed or a decision + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_p <= Process_p_max_pu * Process_p_nom_mod * (Process_status - Process_maintenance_pu * Process_maintenance_status) + Process_status_p_fixed_upper: + description: >- + `Process-status-p-fixed-upper` — a status is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_status <= Process_modules_installed + Process_start_up_p_fixed_upper: + description: >- + `Process-start_up-p-fixed-upper` — a start is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_start_up <= Process_modules_installed + Process_shut_down_p_fixed_upper: + description: >- + `Process-shut_down-p-fixed-upper` — a stop is at most the modules in + place, an explicit row as PyPSA writes it: one where the build is not + modular, and the fixed build's whole count of modules where it is + dims: [scenario, snapshot, process] + where: Process_committable AND NOT (Process_p_nom_extendable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_shut_down <= Process_modules_installed + Process_status_p_nom_variable_upper: + description: "`Process-status-p_nom-variable-upper` — a modular process is on only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_status <= Process_n_mod + Process_start_up_p_nom_variable_upper: + description: "`Process-start_up-p_nom-variable-upper` — a modular process starts only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_start_up <= Process_n_mod + Process_shut_down_p_nom_variable_upper: + description: "`Process-shut_down-p_nom-variable-upper` — a modular process stops only where a module is built" + dims: [scenario, snapshot, process] + where: Process_committable AND Process_p_nom_extendable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_shut_down <= Process_n_mod +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `process` — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$ — whether internal power is gated by an on/off status decision | +| $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays on once started | +| $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays off once stopped | +| $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | +| $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | +| $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | +| $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\Xi \times \mathcal{J}`$ — cost of one start | +| $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\Xi \times \mathcal{J}`$ — cost of one stop | +| $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$ — a bound safely above any feasible internal power — the build cap at full availability, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-status` — how much of a committable process is on: an integer the rows below cap at one, or at the module count where the build is modular | +| $`\mathit{up}^{z}`$ | `Process_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is | +| $`\mathit{dn}^{z}`$ | `Process_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$, data another file declares | +| $`\underline{\mathrm{z}}`$ | `Process_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, data another file declares | +| $`\overline{\mathrm{z}}`$ | `Process_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, data another file declares | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$, data another file declares | +| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$, data another file declares | +| $`z`$ | `Process_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`N^{z}`$ | `Process_n_mod` over $`\mathcal{J}`$ | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`Z`$ | `Process_p_nom_ext` over $`\mathcal{J}`$ | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Process_commitment_opex` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the commitment state a process carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | +| $`\mathit{Process\_commitment\_opex}`$ | `Process_commitment_opex` over $`\Xi`$ | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +#### Subject to + +**`Process_com_p_lower`** + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_p_upper`** + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_transition_start_up`** + +```math +\mathit{up}^{z}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_transition_shut_down`** + +```math +\mathit{dn}^{z}_{\xi,t,j} \ge \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_up_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{UT}^{z}} \mathit{up}^{z}_{\xi,t',j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{UT}^{z}_{\xi,j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_down_time`** + +```math +\sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < \mathrm{DT}^{z}} \mathit{dn}^{z}_{\xi,t',j} \le 1 - u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{DT}^{z}_{\xi,j} > 0 \wedge \mathrm{pos}(t) > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_status_must_stay_up`** + +```math +u^{z}_{\xi,t,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{hold}^{z}_{\xi,t,j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_status_must_stay_down`** + +```math +u^{z}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{rest}^{z}_{\xi,t,j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_ext_p_upper_cap`** + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_ext_p_upper_big_m`** + +```math +z_{\xi,t,j} \le \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_ext_p_lower`** + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \left( Z_{j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,\mathrm{nom}}_{\xi,t,j} \right) + \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} - \mathrm{M}^{z}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_ext_p_lower_nonneg`** + +```math +z_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{nonneg}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_mod_p_lower`** + +```math +z_{\xi,t,j} \ge \underline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_com_mod_p_upper`** + +```math +z_{\xi,t,j} \le \overline{\mathrm{z}}_{\xi,t,j} \cdot \mathrm{z}^{\mathrm{mod}}_{j} \cdot \left( u^{z}_{\xi,t,j} - \gamma^{z}_{\xi,j} \cdot \mu^{z,u}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_status_p_fixed_upper`** + +```math +u^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_start_up_p_fixed_upper`** + +```math +\mathit{up}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_shut_down_p_fixed_upper`** + +```math +\mathit{dn}^{z}_{\xi,t,j} \le \mathrm{N}^{z,\mathrm{fix}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_status_p_nom_variable_upper`** + +```math +u^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_start_up_p_nom_variable_upper`** + +```math +\mathit{up}^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_shut_down_p_nom_variable_upper`** + +```math +\mathit{dn}^{z}_{\xi,t,j} \le N^{z}_{j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +#### Definitions + +**`Process_previous_status`** + +```math +\overleftarrow{u}^{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{\xi,j} & \text{if } \mathrm{pos}(t) = 0 \\ u^{z}_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +**`Process_commitment_opex`** + +```math +\mathit{Process\_commitment\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} u^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{on}}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{up}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{up}}_{\xi,j} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} \mathit{dn}^{z}_{\xi,t,j} \cdot \mathrm{c}^{z,\mathrm{dn}}_{\xi,j} \qquad \forall\, \xi \in \Xi +``` + +#### Variable domains + +**`Process_status`** + +```math +u^{z}_{\xi,t,j} \ge 0, u^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_start_up`** + +```math +\mathit{up}^{z}_{\xi,t,j} \ge 0, \mathit{up}^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_shut_down`** + +```math +\mathit{dn}^{z}_{\xi,t,j} \ge 0, \mathit{dn}^{z}_{\xi,t,j} \in \mathbb{Z} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + diff --git a/docs/examples/pypsa/process_maintenance.md b/docs/examples/pypsa/process_maintenance.md new file mode 100644 index 00000000..0c223e1b --- /dev/null +++ b/docs/examples/pypsa/process_maintenance.md @@ -0,0 +1,418 @@ + + +# Processes, the maintenance + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`, the maintenance rows. It reads `Process_active`, `Process_committable`, `Process_p_nom_ext`, `Process_p_nom_extendable`, `Process_p_nom_max`, `Process_p_nom_min` and 3 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + +relations: + Process_maintenance_cover: + description: >- + the snapshots a maintenance event covers, by the snapshot it starts in — + in a scenario, the start and the snapshots after it, until their + generator weightings reach that scenario's `maintenance_duration`, data prep; no + rows for a process that is + not maintainable + key: { scenario: scenario, process: process, start: snapshot, covered: snapshot } + +parameters: + Process_maintainable: + description: >- + whether a process must be taken off for maintenance within the horizon — in + any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) + dims: [process] + dtype: bool + Process_maintenance_pu: + description: the share of the build a maintenance event takes off + dims: [scenario, process] + Process_maintenance_events: + description: how many maintenance events the horizon holds + dims: [scenario, process] + dtype: int + Process_maintenance_duration: + description: >- + the hours of generator weightings one maintenance event covers — + PyPSA's `maintenance_duration`; no value where the process is not + maintainable. No row reads it: data prep turns it into + `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the + assumptions hold it to the horizon + dims: [scenario, process] + Process_maintenance_start_blocked: + description: >- + true where no maintenance event may start, because the snapshots it + would cover run past the end of the horizon or into one the process does + not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` + and the generator weightings, data prep + dims: [scenario, snapshot, process] + dtype: bool + +variables: + Process_maintenance: + description: >- + `Process-maintenance` — whether a maintainable process is in maintenance: + continuous, and one exactly where an event covers the snapshot + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + bounds: + lower: 0 + upper: 1 + Process_maintenance_start: + description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + absence: zero + domain: binary + Process_maintenance_capacity: + description: >- + `Process-maintenance_capacity` — the chosen build while in maintenance, zero + otherwise: the product the `maintcap` rows linearize + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_p_nom_extendable + AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + absence: zero + bounds: + lower: 0 + Process_maintenance_status: + description: >- + `Process-maintenance_status` — the status while in maintenance, zero + otherwise: the product the `maint-status` rows linearize, so a unit in + maintenance may also be off + dims: [scenario, snapshot, process] + where: >- + Process_maintainable AND Process_committable + AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active + absence: zero + bounds: + lower: 0 + +given: + parameters: + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_p_nom_min: { dims: [scenario, process] } + Process_p_nom_max: { dims: [scenario, process] } + Process_committable: { dims: [process], dtype: bool } + Process_p_nom_mod: { dims: [process] } + Process_active: { dims: [snapshot, process], dtype: bool } + snapshot_weightings_generators: { dims: [snapshot] } + variables: + Process_status: { dims: [scenario, snapshot, process], domain: integer } + Process_p_nom_ext: { dims: [process] } + +constraints: + Process_maint_event_count: + description: "`Process-maint-event-count` — a maintainable process holds its number of maintenance events over the horizon" + dims: [scenario, process] + where: Process_maintainable + expression: sum(Process_maintenance_start, over=snapshot) == Process_maintenance_events + Process_maint_window: + description: >- + `Process-maint-window` — a process is in maintenance exactly where an event it + started covers the snapshot; two events do not overlap, since the + maintenance status is at most one + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active + expression: Process_maintenance == sum(Process_maintenance_start, by=Process_maintenance_cover, over=start, into=covered) + Process_maint_start_horizon: + description: "`Process-maint-start-horizon` — no event starts where it could not run its whole duration" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_active AND Process_maintenance_start_blocked + expression: Process_maintenance_start == 0 + Process_maintcap_upper: + description: >- + `Process-maintcap_upper` — the build taken off is at most the chosen build in + maintenance, and at most the build less its floor out of it + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_ext - Process_p_nom_min * (1 - Process_maintenance) + Process_maintcap_upper_nommax: + description: "`Process-maintcap_upper_nommax` — out of maintenance, no build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity <= Process_p_nom_max * Process_maintenance + Process_maintcap_lower_nommax: + description: "`Process-maintcap_lower_nommax` — in maintenance, the whole chosen build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active + expression: Process_maintenance_capacity >= Process_p_nom_ext - Process_p_nom_max * (1 - Process_maintenance) + Process_maintcap_lower_nommin: + description: "`Process-maintcap_lower_nommin` — in maintenance, at least the floor of the build is taken off" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active AND Process_p_nom_min > 0 + expression: Process_maintenance_capacity >= Process_p_nom_min * Process_maintenance + Process_maint_status_le_status: + description: "`Process-maint-status-le-status` — the status in maintenance is at most the status" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_status_le_maint: + description: "`Process-maint-status-le-maint` — out of maintenance, the status in maintenance is zero" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status <= Process_maintenance + Process_maint_status_lb: + description: "`Process-maint-status-lb` — on and in maintenance, the status in maintenance is one" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_active + expression: Process_maintenance_status >= Process_status + Process_maintenance - 1 + Process_maint_modstatus_le_status: + description: "`Process-maint-modstatus-le-status` — the modules on in maintenance are at most the modules on" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_status + Process_maint_modstatus_le_maint: + description: >- + `Process-maint-modstatus-le-maint` — out of maintenance, no module is on in + maintenance; in it, at most the modules the build cap holds + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status <= Process_p_nom_max / Process_p_nom_mod * Process_maintenance + Process_maint_modstatus_lb: + description: "`Process-maint-modstatus-lb` — in maintenance, every module on is on in maintenance" + dims: [scenario, snapshot, process] + where: Process_maintainable AND Process_committable AND Process_p_nom_mod > 0 AND Process_active + expression: Process_maintenance_status >= Process_status - Process_p_nom_max / Process_p_nom_mod * (1 - Process_maintenance) + +assumptions: + Process_maintenance_events_positive: + holds: "Process_maintenance_events > 0" + where: "Process_maintainable" + description: >- + a maintainable process with no event schedules no maintenance — + PyPSA refuses it (`consistency.py:1516`) + Process_maintenance_duration_positive: + holds: "Process_maintenance_duration > 0" + where: "Process_maintainable" + description: >- + an event that covers no hours is no maintenance window — PyPSA + refuses it (`consistency.py:1506`) + Process_maintenance_duration_fits_the_horizon: + holds: "Process_maintenance_duration <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + one event longer than the horizon, in generator weightings, blocks + every start and makes the event count infeasible — PyPSA refuses it + (`consistency.py:1527`) + Process_maintenance_events_fit_the_horizon: + holds: "Process_maintenance_duration * Process_maintenance_events <= sum(snapshot_weightings_generators, over=snapshot)" + where: "Process_maintainable" + description: >- + the events together longer than the horizon, in generator + weightings, cannot all be scheduled — PyPSA refuses it + (`consistency.py:1539`) + Process_maintenance_build_cap_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_p_nom_extendable" + description: >- + the `maintcap` rows hold the chosen build in maintenance against + `p_nom_max`, so an infinite cap is an infinite coefficient — PyPSA + refuses it (`consistency.py:1551`) + Process_maintenance_module_count_is_finite: + holds: "Process_p_nom_max < inf" + where: "Process_maintainable AND Process_committable AND NOT Process_p_nom_extendable AND Process_p_nom_mod > 0" + description: >- + the `maint-modstatus` rows bound the modules on in maintenance by + `p_nom_max / p_nom_mod`, so an infinite cap is an infinite + coefficient. PyPSA does not check it, and HiGHS refuses the model + (`constraints.py:500-503`) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` with $`\mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `process` with $`\mathrm{Process\_maintenance\_cover} \subseteq \Xi \times \mathcal{J} \times \mathcal{T} \times \mathcal{T}`$ — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | +| $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$ — the share of the build a maintenance event takes off | +| $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\Xi \times \mathcal{J}`$ — how many maintenance events the horizon holds | +| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$, data another file declares | +| $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$, data another file declares | +| $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`Z`$ | `Process_p_nom_ext` over $`\mathcal{J}`$ | + +#### Subject to + +**`Process_maint_event_count`** + +```math +\sum_{t \in \mathcal{T}} \mu^{z,\mathrm{up}}_{\xi,t,j} = \mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +**`Process_maint_window`** + +```math +\mu^{z}_{\xi,t,j} = \sum_{t' \in \mathcal{T} \,:\, \left( \xi,\ j,\ t',\ t \right) \in \mathrm{Process\_maintenance\_cover}} \mu^{z,\mathrm{up}}_{\xi,t',j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_start_horizon`** + +```math +\mu^{z,\mathrm{up}}_{\xi,t,j} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} \wedge \mathrm{blk}^{z}_{\xi,t,j} +``` + +**`Process_maintcap_upper`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le Z_{j} - \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintcap_upper_nommax`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \le \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintcap_lower_nommax`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge Z_{j} - \overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintcap_lower_nommin`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} \wedge \underline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} > 0 +``` + +**`Process_maint_status_le_status`** + +```math +\mu^{z,u}_{\xi,t,j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_status_le_maint`** + +```math +\mu^{z,u}_{\xi,t,j} \le \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_status_lb`** + +```math +\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} + \mu^{z}_{\xi,t,j} - 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_modstatus_le_status`** + +```math +\mu^{z,u}_{\xi,t,j} \le u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_modstatus_le_maint`** + +```math +\mu^{z,u}_{\xi,t,j} \le \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \mu^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maint_modstatus_lb`** + +```math +\mu^{z,u}_{\xi,t,j} \ge u^{z}_{\xi,t,j} - \frac{\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j}}{\mathrm{z}^{\mathrm{mod}}_{j}} \cdot \left( 1 - \mu^{z}_{\xi,t,j} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \wedge \mathrm{on}^{z}_{t,j} +``` + +#### Variable domains + +**`Process_maintenance`** + +```math +0 \le \mu^{z}_{\xi,t,j} \le 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_start`** + +```math +\mu^{z,\mathrm{up}}_{\xi,t,j} \in \{0, 1\} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_capacity`** + +```math +\mu^{z,\mathrm{nom}}_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_maintenance_status`** + +```math +\mu^{z,u}_{\xi,t,j} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \left( \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +#### Assumptions + +**`Process_maintenance_events_positive`** + +```math +\mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} > 0 \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +**`Process_maintenance_duration_positive`** + +```math +\tau^{z,\mathrm{mnt}}_{\xi,j} > 0 \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +**`Process_maintenance_duration_fits_the_horizon`** + +```math +\tau^{z,\mathrm{mnt}}_{\xi,j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +**`Process_maintenance_events_fit_the_horizon`** + +```math +\tau^{z,\mathrm{mnt}}_{\xi,j} \cdot \mathrm{n}^{z,\mathrm{mnt}}_{\xi,j} \le \sum_{t \in \mathcal{T}} \mathrm{w}^{\mathrm{gen}}_{t} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} +``` + +**`Process_maintenance_build_cap_is_finite`** + +```math +\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} < \infty \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} +``` + +**`Process_maintenance_module_count_is_finite`** + +```math +\overline{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} < \infty \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} \,:\, \mathrm{mnt}^{z}_{j} \wedge \mathrm{com}^{z}_{j} \wedge \neg \mathrm{ext}^{z}_{j} \wedge \mathrm{z}^{\mathrm{mod}}_{j} > 0 +``` + diff --git a/docs/examples/pypsa/process_ramping.md b/docs/examples/pypsa/process_ramping.md new file mode 100644 index 00000000..ab830860 --- /dev/null +++ b/docs/examples/pypsa/process_ramping.md @@ -0,0 +1,390 @@ + + +# Processes, the ramping + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`, the ramping rows. It reads `Process_active`, `Process_big_m`, `Process_committable`, `Process_p`, `Process_p_nom_committed`, `Process_p_nom_effective` and 8 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + process: + description: generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + Process_ramp_limit_up: + description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] + Process_ramp_limit_down: + description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time + dims: [scenario, snapshot, process] + Process_ramp_limit_start_up: + description: most internal power in the snapshot a process starts, per unit of nominal power + dims: [scenario, process] + Process_ramp_limit_shut_down: + description: most internal power in the snapshot before a process stops, per unit of nominal power + dims: [scenario, process] + Process_p_init: + description: >- + the internal power a process brought into the horizon — PyPSA's `p_init`, read + only where the process came in running; no value means it is unknown, so + the process carries no ramp row at the first snapshot + dims: [scenario, process] + +given: + parameters: + Process_p_nom_extendable: { dims: [process], dtype: bool } + Process_committable: { dims: [process], dtype: bool } + Process_status_initial: { dims: [scenario, process], dtype: int } + Process_p_nom_mod: { dims: [process] } + Process_big_m: { dims: [scenario, process] } + Process_active: { dims: [snapshot, process], dtype: bool } + variables: + Process_p: { dims: [scenario, snapshot, process] } + Process_status: { dims: [scenario, snapshot, process], domain: integer } + Process_start_up: { dims: [scenario, snapshot, process], domain: integer } + Process_shut_down: { dims: [scenario, snapshot, process], domain: integer } + Process_p_nom_ext: { dims: [process] } + expressions: + Process_p_nom_effective: { dims: [scenario, process] } + Process_previous_status: { dims: [scenario, snapshot, process] } + Process_p_nom_committed: { dims: [scenario, process] } + +expressions: + Process_previous_p: + description: >- + the internal power a process carries into a snapshot — at the first, the + `p_init` it brought in where it came in running and nothing where it + came in off; the previous snapshot's after that + dims: [scenario, snapshot, process] + cases: + opening: { when: "position(snapshot) == 0", expression: Process_status_initial * Process_p_init } + otherwise: shift(Process_p, along=snapshot, offset=1) + Process_ramp_up_rate: + description: >- + the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the + full build where it has none, since a start-up ramp alone builds the row + dims: [scenario, snapshot, process] + cases: + given: { when: Process_ramp_limit_up, expression: Process_ramp_limit_up } + otherwise: 1 + Process_ramp_down_rate: + description: >- + the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or + the full build where it has none, since a shut-down ramp alone builds the row + dims: [scenario, snapshot, process] + cases: + given: { when: Process_ramp_limit_down, expression: Process_ramp_limit_down } + otherwise: 1 + Process_start_up_rate: + description: >- + the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, + or the full build where it has none + dims: [scenario, process] + cases: + given: { when: Process_ramp_limit_start_up, expression: Process_ramp_limit_start_up } + otherwise: 1 + Process_shut_down_rate: + description: >- + the shut-down ramp a process's down row reads — PyPSA's + `ramp_limit_shut_down`, or the full build where it has none + dims: [scenario, process] + cases: + given: { when: Process_ramp_limit_shut_down, expression: Process_ramp_limit_shut_down } + otherwise: 1 + Process_ramp_up_allowance: + description: >- + how far a process may raise internal power between two snapshots — its ramp + limit of the build while it stays on, plus its start-up ramp in the + snapshot it turns on + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_up_rate * Process_p_nom_committed * Process_previous_status + + Process_start_up_rate * Process_p_nom_committed + * (Process_status - Process_previous_status) + otherwise: Process_ramp_up_rate * Process_p_nom_effective + Process_ramp_down_allowance: + description: >- + how far a process may lower internal power between two snapshots — its ramp + limit of the build while it stays on, plus its shut-down ramp in the + snapshot it turns off + dims: [scenario, snapshot, process] + cases: + committed: + when: Process_committable + expression: >- + Process_ramp_down_rate * Process_p_nom_committed * Process_status + + Process_shut_down_rate * Process_p_nom_committed + * (Process_previous_status - Process_status) + otherwise: Process_ramp_down_rate * Process_p_nom_effective + +constraints: + Process_p_ramp_limit_up_run_big_m: + description: >- + `Process-p-ramp_limit_up-run-bigM` — a committed extendable process + raises internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns on + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_ramp_up_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_previous_status + Process_p_ramp_limit_up_start_big_m: + description: >- + `Process-p-ramp_limit_up-start-bigM` — in the snapshot it turns on, a + committed extendable process ramps no further than its start-up ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_p - Process_previous_p <= + Process_start_up_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_start_up + Process_p_ramp_limit_down_run_big_m: + description: >- + `Process-p-ramp_limit_down-run-bigM` — a committed extendable process + lowers internal power no faster than its limit of the chosen build; the big M + releases the row in the snapshot it turns off + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_ramp_down_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_status + Process_p_ramp_limit_down_shut_big_m: + description: >- + `Process-p-ramp_limit_down-shut-bigM` — in the snapshot it turns off, + a committed extendable process ramps no further than its shut-down ramp of + the chosen build; the big M releases the row everywhere else + dims: [scenario, snapshot, process] + where: >- + Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0) + AND (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: >- + Process_previous_p - Process_p <= + Process_shut_down_rate * Process_p_nom_ext + + Process_big_m - Process_big_m * Process_shut_down + Process_p_ramp_limit_up: + description: >- + `Process-p-ramp_limit_up` — a process raises internal power no faster than + its ramp limit of the build, and a committed one no further than its + start-up ramp in the snapshot it turns on. A process that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead + dims: [scenario, snapshot, process] + where: >- + (Process_ramp_limit_up OR Process_ramp_limit_start_up) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: Process_p - Process_previous_p <= Process_ramp_up_allowance + Process_p_ramp_limit_down: + description: >- + `Process-p-ramp_limit_down` — a process lowers internal power no faster than + its ramp limit of the build, and a committed one no further than its + shut-down ramp in the snapshot it turns off. A process that came into the + horizon running carries a row at the first snapshot only where its + `p_init` gives the internal power it brought in, and no process carries one at + the start of a later investment period — nor does any process a big M releases instead + dims: [scenario, snapshot, process] + where: >- + (Process_ramp_limit_down OR Process_ramp_limit_shut_down) + AND NOT (Process_committable AND Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) + AND (position(snapshot, by=snapshot_period, within=period) > 0 OR (position(snapshot) == 0 AND (Process_status_initial == 0 OR Process_p_init))) + AND Process_active + expression: Process_previous_p - Process_p <= Process_ramp_down_allowance + +assumptions: + Process_came_in_running_unless_committable: + holds: "Process_status_initial == 1" + where: "NOT Process_committable AND (Process_ramp_limit_up OR Process_ramp_limit_down)" + description: >- + PyPSA reads `up_time_before` of a process that is not committable in its + ramp rows. Where it is zero, PyPSA builds a row at the first snapshot + with nothing carried in, and caps the process there at zero, or at its + start-up ramp where another process of the component is committable with a + fixed build (`constraints.py:1091-1094`, `1110-1112`). PyPSA documents + the attribute as read only for a committable process and does not check + it. PyPSA has not decided which row is intended (PyPSA/PyPSA#1943). The + spec does not state that row, so it refuses the data +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `process` — generalized multi-port converters, each with an internal power that every port draws or delivers at its own rate | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | +| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$ — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$, data another file declares | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$, data another file declares | +| $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$, data another file declares | +| $`z`$ | `Process_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`\mathit{up}^{z}`$ | `Process_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`\mathit{dn}^{z}`$ | `Process_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | +| $`Z`$ | `Process_p_nom_ext` over $`\mathcal{J}`$ | +| $`\widetilde{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_effective` over $`\Xi \times \mathcal{J}`$, an expression another file defines | +| $`\overleftarrow{u}^{z}`$ | `Process_previous_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, an expression another file defines | +| $`\widehat{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_committed` over $`\Xi \times \mathcal{J}`$, an expression another file defines | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{z}`$ | `Process_previous_p` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the internal power a process carries into a snapshot — at the first, the `p_init` it brought in where it came in running and nothing where it came in off; the previous snapshot's after that | +| $`\widetilde{\mathrm{ru}}^{z}`$ | `Process_ramp_up_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's up row reads — PyPSA's `ramp_limit_up`, or the full build where it has none, since a start-up ramp alone builds the row | +| $`\widetilde{\mathrm{rd}}^{z}`$ | `Process_ramp_down_rate` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — the ramp limit a process's down row reads — PyPSA's `ramp_limit_down`, or the full build where it has none, since a shut-down ramp alone builds the row | +| $`\widetilde{\mathrm{ru}}^{z,\mathrm{up}}`$ | `Process_start_up_rate` over $`\Xi \times \mathcal{J}`$ — the start-up ramp a process's up row reads — PyPSA's `ramp_limit_start_up`, or the full build where it has none | +| $`\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}`$ | `Process_shut_down_rate` over $`\Xi \times \mathcal{J}`$ — the shut-down ramp a process's down row reads — PyPSA's `ramp_limit_shut_down`, or the full build where it has none | +| $`\Delta^{z,+}`$ | `Process_ramp_up_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may raise internal power between two snapshots — its ramp limit of the build while it stays on, plus its start-up ramp in the snapshot it turns on | +| $`\Delta^{z,-}`$ | `Process_ramp_down_allowance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — how far a process may lower internal power between two snapshots — its ramp limit of the build while it stays on, plus its shut-down ramp in the snapshot it turns off | + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + +#### Subject to + +**`Process_p_ramp_limit_up_run_big_m`** + +```math +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_p_ramp_limit_up_start_big_m`** + +```math +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{up}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_p_ramp_limit_down_run_big_m`** + +```math +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot u^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_p_ramp_limit_down_shut_big_m`** + +```math +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot Z_{j} + \mathrm{M}^{z}_{\xi,j} - \mathrm{M}^{z}_{\xi,j} \cdot \mathit{dn}^{z}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \wedge \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_p_ramp_limit_up`** + +```math +z_{\xi,t,j} - \overleftarrow{z}_{\xi,t,j} \le \Delta^{z,+}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +**`Process_p_ramp_limit_down`** + +```math +\overleftarrow{z}_{\xi,t,j} - z_{\xi,t,j} \le \Delta^{z,-}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \left( \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \right) \wedge \neg \left( \mathrm{com}^{z}_{j} \wedge \mathrm{ext}^{z}_{j} \wedge \neg \left( \mathrm{z}^{\mathrm{mod}}_{j} > 0 \right) \right) \wedge \left( \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) > 0 \vee \mathrm{pos}(t) = 0 \wedge \left( \mathrm{u}^{z,0}_{\xi,j} = 0 \vee \mathrm{z}^{0}_{\xi,j} \text{ is defined} \right) \right) \wedge \mathrm{on}^{z}_{t,j} +``` + +#### Definitions + +**`Process_previous_p`** + +```math +\overleftarrow{z}_{\xi,t,j} = \begin{cases} \mathrm{u}^{z,0}_{\xi,j} \cdot \mathrm{z}^{0}_{\xi,j} & \text{if } \mathrm{pos}(t) = 0 \\ z_{\xi,t - 1,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +**`Process_ramp_up_rate`** + +```math +\widetilde{\mathrm{ru}}^{z}_{\xi,t,j} = \begin{cases} \mathrm{ru}^{z}_{\xi,t,j} & \text{if } \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +**`Process_ramp_down_rate`** + +```math +\widetilde{\mathrm{rd}}^{z}_{\xi,t,j} = \begin{cases} \mathrm{rd}^{z}_{\xi,t,j} & \text{if } \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +**`Process_start_up_rate`** + +```math +\widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} = \begin{cases} \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} & \text{if } \mathrm{ru}^{z,\mathrm{up}}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +``` + +**`Process_shut_down_rate`** + +```math +\widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} = \begin{cases} \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} & \text{if } \mathrm{rd}^{z,\mathrm{dn}}_{\xi,j} \text{ is defined} \\ 1 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ j \in \mathcal{J} +``` + +**`Process_ramp_up_allowance`** + +```math +\Delta^{z,+}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \overleftarrow{u}^{z}_{\xi,t,j} + \widetilde{\mathrm{ru}}^{z,\mathrm{up}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( u^{z}_{\xi,t,j} - \overleftarrow{u}^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{ru}}^{z}_{\xi,t,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +**`Process_ramp_down_allowance`** + +```math +\Delta^{z,-}_{\xi,t,j} = \begin{cases} \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot u^{z}_{\xi,t,j} + \widetilde{\mathrm{rd}}^{z,\mathrm{dn}}_{\xi,j} \cdot \widehat{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} \cdot \left( \overleftarrow{u}^{z}_{\xi,t,j} - u^{z}_{\xi,t,j} \right) & \text{if } \mathrm{com}^{z}_{j} \\ \widetilde{\mathrm{rd}}^{z}_{\xi,t,j} \cdot \widetilde{\mathrm{z}}^{\mathrm{nom}}_{\xi,j} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} +``` + +#### Assumptions + +**`Process_came_in_running_unless_committable`** + +```math +\mathrm{u}^{z,0}_{\xi,j} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ j \in \mathcal{J} \,:\, \neg \mathrm{com}^{z}_{j} \wedge \left( \mathrm{ru}^{z}_{\xi,t,j} \text{ is defined} \vee \mathrm{rd}^{z}_{\xi,t,j} \text{ is defined} \right) +``` + diff --git a/docs/examples/pypsa/security.md b/docs/examples/pypsa/security.md new file mode 100644 index 00000000..63c13034 --- /dev/null +++ b/docs/examples/pypsa/security.md @@ -0,0 +1,262 @@ + + +# Security + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the security-constrained flows over the outages. It reads `Line_s_max_pu`, `Line_s_monitored`, `Line_s_nom`, `Line_s_nom_ext`, `Line_s_nom_extendable`, `Transformer_s_max_pu` and 4 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + line: + description: passive branches, each between two buses, their flow set by impedance + transformer: + description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised + outage: + description: >- + the passive branches a security-constrained run takes out one at a time + — PyPSA's `branch_outages`, each a line or a transformer; none on a + plain run + +relations: + Outage_line: + description: the line an outage takes out; an outage of a transformer has no row + key: outage + values: line + Outage_transformer: + description: the transformer an outage takes out; an outage of a line has no row + key: outage + values: transformer + +parameters: + Line_BODF: + description: >- + the share of an outaged branch's flow a line takes on when that branch + goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row + only where the line and the outage share a sub-network, -1 at the + outaged line itself + dims: [line, outage] + Transformer_BODF: + description: >- + the share of an outaged branch's flow a transformer takes on when that + branch goes out, as a line's; a row only where the transformer and the + outage share a sub-network + dims: [transformer, outage] + +given: + parameters: + Line_s_nom: { dims: [scenario, line] } + Line_s_nom_extendable: { dims: [line], dtype: bool } + Line_s_max_pu: { dims: [scenario, snapshot, line] } + Transformer_s_nom: { dims: [scenario, transformer] } + Transformer_s_nom_extendable: { dims: [transformer], dtype: bool } + Transformer_s_max_pu: { dims: [scenario, snapshot, transformer] } + variables: + Line_s_nom_ext: { dims: [line] } + Transformer_s_nom_ext: { dims: [transformer] } + expressions: + Line_s_monitored: { dims: [scenario, snapshot, line] } + Transformer_s_monitored: { dims: [scenario, snapshot, transformer] } + +expressions: + Outage_s: + description: >- + the flow an outage takes off its branch — the outaged line's or + transformer's flow before it goes out + dims: [scenario, snapshot, outage] + cases: + line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } + otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) + +constraints: + Line_fix_s_lower_security: + description: >- + `Line-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at least the negative + of its rating: its flow takes on its share of the outaged branch's + flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom + Line_fix_s_upper_security: + description: >- + `Line-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, a fixed line carries at most its rating: its + flow takes on its share of the outaged branch's flow. PyPSA names + one row per outaged component `c` and sub-network `n`; this block + states them all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: not Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom + Line_ext_s_lower_security: + description: >- + `Line-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at least the + negative of its rating of the chosen build: its flow takes on its + share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s >= -Line_s_max_pu * Line_s_nom_ext + Line_ext_s_upper_security: + description: >- + `Line-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` — + after any one outage, an extendable line carries at most its rating + of the chosen build: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, line, outage] + where: Line_s_nom_extendable AND Line_BODF + expression: Line_s_monitored + Line_BODF * Outage_s <= Line_s_max_pu * Line_s_nom_ext + Transformer_fix_s_lower_security: + description: >- + `Transformer-fix-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at least the + negative of its rating: its flow takes on its share of the outaged + branch's flow. PyPSA names one row per outaged component `c` and + sub-network `n`; this block states them all over the outage + dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper_security: + description: >- + `Transformer-fix-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, a fixed transformer carries at most its + rating: its flow takes on its share of the outaged branch's flow. + PyPSA names one row per outaged component `c` and sub-network `n`; + this block states them all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: not Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower_security: + description: >- + `Transformer-ext-s-lower-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at least + the negative of its rating of the chosen build: its flow takes on + its share of the outaged branch's flow. PyPSA names one row per + outaged component `c` and sub-network `n`; this block states them + all over the outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper_security: + description: >- + `Transformer-ext-s-upper-security-for-{c}-outage-in-sub-network-{n}` + — after any one outage, an extendable transformer carries at most + its rating of the chosen build: its flow takes on its share of the + outaged branch's flow. PyPSA names one row per outaged component + `c` and sub-network `n`; this block states them all over the + outage dimension + dims: [scenario, snapshot, transformer, outage] + where: Transformer_s_nom_extendable AND Transformer_BODF + expression: Transformer_s_monitored + Transformer_BODF * Outage_s <= Transformer_s_max_pu * Transformer_s_nom_ext +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$ — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | +| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\Xi \times \mathcal{K}`$, data another file declares | +| $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$, data another file declares | +| $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, data another file declares | +| $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\Xi \times \mathcal{M}`$, data another file declares | +| $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$, data another file declares | +| $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, data another file declares | +| $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ | +| $`\Sigma`$ | `Transformer_s_nom_ext` over $`\mathcal{M}`$ | +| $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, an expression another file defines | +| $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, an expression another file defines | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\hat{s}`$ | `Outage_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}^{\mathrm{out}}`$ — the flow an outage takes off its branch — the outaged line's or transformer's flow before it goes out | + +#### Subject to + +**`Line_fix_s_lower_security`** + +```math +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +**`Line_fix_s_upper_security`** + +```math +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot \mathrm{s}^{\mathrm{nom}}_{\xi,k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +**`Line_ext_s_lower_security`** + +```math +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +**`Line_ext_s_upper_security`** + +```math +\check{s}_{\xi,t,k} + \beta_{k,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\mathrm{s}}_{\xi,t,k} \cdot S_{k} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ k \in \mathcal{K},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{s}_{k} \wedge \beta_{k,\kappa} \text{ is defined} +``` + +**`Transformer_fix_s_lower_security`** + +```math +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +**`Transformer_fix_s_upper_security`** + +```math +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +**`Transformer_ext_s_lower_security`** + +```math +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +**`Transformer_ext_s_upper_security`** + +```math +\check{\sigma}_{\xi,t,m} + \beta^{\sigma}_{m,\kappa} \cdot \hat{s}_{\xi,t,\kappa} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ \kappa \in \mathcal{K}^{\mathrm{out}} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \beta^{\sigma}_{m,\kappa} \text{ is defined} +``` + +#### Definitions + +**`Outage_s`** + +```math +\hat{s}_{\xi,t,\kappa} = \begin{cases} \check{s}_{\xi,t,\mathrm{Outage\_line}(\kappa)} & \text{if } \mathrm{Outage\_line}(\kappa) \text{ is defined} \\ \check{\sigma}_{\xi,t,\mathrm{Outage\_transformer}(\kappa)} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ \kappa \in \mathcal{K}^{\mathrm{out}} +``` + diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md new file mode 100644 index 00000000..25f0e613 --- /dev/null +++ b/docs/examples/pypsa/settings.md @@ -0,0 +1,203 @@ + + +# Settings + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the weightings, the risk preference and the flags every topic reads, and the readings of the totals whose readers may be left out. It reads `Carrier_additions`, `operational_limit`, `primary_energy`, `scenario_opex`, `tech_capacity_expansion`, `transmission_expansion_cost` and 1 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + +parameters: + snapshot_weightings_objective: + description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost + dims: [snapshot] + scenario_weight: + description: PyPSA's `scenario_weightings.weight` — the probability of a future + dims: [scenario] + CVaR_omega: + description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model + dims: [] + period_weight_objective: + description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs + dims: [period] + period_weight_years: + description: >- + PyPSA's `investment_period_weightings.years` — what a period's energy + weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it + only under `multi_investment_periods`, so data prep feeds one otherwise + dims: [period] + snapshot_weightings_stores: + description: PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance + dims: [snapshot] + snapshot_weightings_generators: + description: PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total + dims: [snapshot] + transmission_losses: + description: >- + whether the network dissipates transmission losses — PyPSA's + `transmission_losses` read as a flag; its mode, tangents or secants, + only decides how data prep fills the `segment` axis, the rows are the + same; false with no segments is a lossless run. A security-constrained + run over a network with passive branches builds no loss: PyPSA does not + hand the keyword to `create_model` (`abstract.py:437-441`) but to the + solver (`:491`), so data prep feeds false there + dims: [] + dtype: bool + GlobalConstraint_counts_snapshot: + description: >- + whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every + snapshot where the row names none, and only that period's where it + names one, data prep. A row that names a period the run does not model + has no label here, as PyPSA skips it (`global_constraints.py:377`); + PyPSA reads the column only under `multi_investment_periods`, and fails + on a row that names a period without it (`global_constraints.py:375`) + dims: [scenario, global_constraint, snapshot] + dtype: bool + +given: + expressions: + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + +expressions: + GlobalConstraint_energy_weight: + description: >- + what one unit of power at a snapshot counts for in a row — the + generator weighting times the years of the snapshot's period, where the + row counts the snapshot, and nothing where it does not + dims: [scenario, global_constraint, snapshot] + cases: + counted: + when: GlobalConstraint_counts_snapshot + expression: snapshot_weightings_generators * at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + otherwise: 0 + GlobalConstraint_snapshot_closes: + description: one at the last snapshot a row counts, and zero elsewhere + dims: [scenario, global_constraint, snapshot] + cases: + last_counted: + when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) + expression: 1 + otherwise: 0 +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | +| $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | +| $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | +| $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.years` — what a period's energy weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise | +| $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | +| $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression another file defines — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression another file defines — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression another file defines — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | +| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | + +#### Definitions + +**`GlobalConstraint_energy_weight`** + +```math +\mathit{w}^{\mathrm{gc}}_{\xi,g,t} = \begin{cases} \mathrm{w}^{\mathrm{gen}}_{t} \cdot \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{in}_{\xi,g,t} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T} +``` + +**`GlobalConstraint_snapshot_closes`** + +```math +\mathit{last}_{\xi,g,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{\xi,g,t} \wedge \neg \mathrm{in}_{\xi,g,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T} +``` + diff --git a/docs/examples/pypsa/storage_unit.md b/docs/examples/pypsa/storage_unit.md new file mode 100644 index 00000000..7f6003f7 --- /dev/null +++ b/docs/examples/pypsa/storage_unit.md @@ -0,0 +1,828 @@ + + +# Storage units + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `StorageUnit`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + storage_unit: + description: storage units, dispatch and store behind one bus connection + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + StorageUnit_carrier: + description: the carrier a storage unit converts from + key: storage_unit + values: carrier + StorageUnit_bus: + description: the bus a storage unit sits on + key: storage_unit + values: bus + +parameters: + StorageUnit_active: + description: whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, storage_unit] + dtype: bool + StorageUnit_capital_weight: + description: the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [storage_unit] + StorageUnit_first_active: + description: >- + one in the first period a storage unit stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a storage unit + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, storage_unit] + StorageUnit_p_nom_min: + description: least nominal power an extendable storage unit may be built at + dims: [scenario, storage_unit] + StorageUnit_p_nom_max: + description: most nominal power an extendable storage unit may be built at + dims: [scenario, storage_unit] + StorageUnit_capital_cost: + description: cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, storage_unit] + StorageUnit_p_nom_set: + description: a given nominal power for an extendable storage unit; one without a value has no row here + dims: [scenario, storage_unit] + StorageUnit_p_nom: + description: nominal power + dims: [scenario, storage_unit] + StorageUnit_p_nom_extendable: + description: whether the nominal power is a decision + dims: [storage_unit] + dtype: bool + StorageUnit_p_min_pu: + description: most storing, per unit of nominal power and negated + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_max_pu: + description: most dispatch, per unit of nominal power + dims: [scenario, snapshot, storage_unit] + StorageUnit_max_hours: + description: energy capacity, as hours of dispatch at nominal power + dims: [scenario, storage_unit] + StorageUnit_efficiency_store: + description: share of the power drawn from the bus that becomes charge + dims: [scenario, storage_unit] + StorageUnit_efficiency_dispatch: + description: share of the charge drawn down that reaches the bus + dims: [scenario, storage_unit] + StorageUnit_sign: + description: >- + the sign net dispatch enters its bus's balance with — PyPSA's `sign`, + `1` unless given. PyPSA refuses one that differs by scenario + (`consistency.py:1187`) + dims: [storage_unit] + StorageUnit_retention: + description: share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep + dims: [scenario, snapshot, storage_unit] + StorageUnit_inflow: + description: energy arriving per hour, a river into a reservoir + dims: [scenario, snapshot, storage_unit] + StorageUnit_state_of_charge_initial: + description: charge held before the first snapshot + dims: [scenario, storage_unit] + StorageUnit_cyclic_state_of_charge: + description: whether the horizon closes on itself instead of opening on the initial charge + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_cyclic_state_of_charge_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + charge on to the next; it overrides `cyclic_state_of_charge` and + `state_of_charge_initial_per_period`. PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_state_of_charge_initial_per_period: + description: >- + whether each investment period opens on the initial charge instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, storage_unit] + dtype: bool + StorageUnit_opens_late: + description: >- + whether a snapshot is the first a storage unit stands in, where that is + not the first of the horizon — PyPSA's `active.cumsum() == 1` over the + snapshots it stands in, past the first snapshot, data prep; false in a + run where every unit stands throughout + dims: [snapshot, storage_unit] + dtype: bool + StorageUnit_inactive_snapshots: + description: >- + how many snapshots a storage unit does not stand in — PyPSA's + `(~active).sum()`, data prep. A cyclic unit reaches back this many + snapshots further, so it closes on the last snapshot it stands in + dims: [storage_unit] + dtype: int + StorageUnit_marginal_cost: + description: cost of one unit of dispatch + dims: [scenario, snapshot, storage_unit] + StorageUnit_marginal_cost_quadratic: + description: cost of the square of one unit of dispatch; storing is not charged + dims: [scenario, snapshot, storage_unit] + StorageUnit_marginal_cost_storage: + description: cost of one unit of charge held over one snapshot + dims: [scenario, snapshot, storage_unit] + StorageUnit_spill_cost: + description: cost of one unit of inflow passed on unused + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_set: + description: a given net dispatch schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_dispatch_set: + description: a given dispatch schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_p_store_set: + description: a given charging schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_state_of_charge_set: + description: a given charge schedule; a unit without one has no row here + dims: [scenario, snapshot, storage_unit] + StorageUnit_primary_energy_weight: + description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row + dims: [scenario, global_constraint, storage_unit] + StorageUnit_operational_limit_weight: + description: one where the storage unit is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, storage_unit] + StorageUnit_tech_capacity_weight: + description: >- + one where the storage unit is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, storage_unit] + +variables: + StorageUnit_p_dispatch: + description: "`StorageUnit-p_dispatch` — power delivered to the bus" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_p_store: + description: "`StorageUnit-p_store` — power drawn from the bus into charge" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_state_of_charge: + description: "`StorageUnit-state_of_charge` — energy held at the end of a snapshot" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + StorageUnit_spill: + description: >- + `StorageUnit-spill` — inflow passed on unused. Zero where there is no + inflow, so the balance keeps its row there; the bounds are PyPSA's, on + the variable rather than as rows + dims: [scenario, snapshot, storage_unit] + where: "StorageUnit_inflow > 0 AND StorageUnit_active" + absence: zero + bounds: + lower: 0 + upper: StorageUnit_inflow + StorageUnit_p_nom_ext: + description: >- + `StorageUnit-p_nom` — nominal power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [storage_unit] + where: StorageUnit_p_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + period_weight_years: { dims: [period] } + snapshot_weightings_stores: { dims: [snapshot] } + GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } + expressions: + GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: StorageUnit_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: StorageUnit_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: StorageUnit_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: StorageUnit_opex } + Carrier_additions: { dims: [period, carrier], term: StorageUnit_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: StorageUnit_injection } + +expressions: + StorageUnit_charge_carried_in: + description: >- + the charge a unit opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial charge, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A unit built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon + dims: [scenario, snapshot, storage_unit] + cases: + cyclic: + when: >- + StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: >- + StorageUnit_retention + * shift(shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=StorageUnit_inactive_snapshots, edge='wrap') + opening: + when: >- + NOT StorageUnit_cyclic_state_of_charge AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND NOT StorageUnit_state_of_charge_initial_per_period + AND (position(snapshot) == 0 OR StorageUnit_opens_late) + expression: StorageUnit_state_of_charge_initial + period_cyclic: + when: StorageUnit_cyclic_state_of_charge_per_period + expression: >- + StorageUnit_retention + * shift(StorageUnit_state_of_charge, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: >- + StorageUnit_state_of_charge_initial_per_period AND NOT StorageUnit_cyclic_state_of_charge_per_period + AND position(snapshot, by=snapshot_period, within=period) == 0 + expression: StorageUnit_state_of_charge_initial + otherwise: StorageUnit_retention * shift(StorageUnit_state_of_charge, along=snapshot, offset=1) + StorageUnit_closing_weight: + description: >- + what the charge a unit holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the unit reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [scenario, global_constraint, snapshot, storage_unit] + cases: + per_period: + when: StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT StorageUnit_state_of_charge_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 + StorageUnit_primary_energy: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + StorageUnit_operational_limit: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + StorageUnit_tech_capacity_expansion: + expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + StorageUnit_opex: + expression: >- + sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + StorageUnit_additions: + expression: >- + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + StorageUnit_injection: + expression: >- + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + +constraints: + StorageUnit_fix_p_dispatch_lower: + description: "`StorageUnit-fix-p_dispatch-lower` — dispatch is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch >= 0 + StorageUnit_fix_p_dispatch_upper: + description: "`StorageUnit-fix-p_dispatch-upper` — a fixed unit dispatches at most its nominal power" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom + StorageUnit_fix_p_store_lower: + description: "`StorageUnit-fix-p_store-lower` — storing is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store >= 0 + StorageUnit_fix_p_store_upper: + description: >- + `StorageUnit-fix-p_store-upper` — a fixed unit stores at most its + nominal power, the minimum-per-unit column carrying that cap negated + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom + StorageUnit_fix_state_of_charge_lower: + description: "`StorageUnit-fix-state_of_charge-lower` — charge is non-negative" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge >= 0 + StorageUnit_fix_state_of_charge_upper: + description: "`StorageUnit-fix-state_of_charge-upper` — a fixed unit holds at most its hours at nominal power" + dims: [scenario, snapshot, storage_unit] + where: not StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom + StorageUnit_ext_p_dispatch_lower: + description: "`StorageUnit-ext-p_dispatch-lower` — dispatch is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch >= 0 + StorageUnit_ext_p_dispatch_upper: + description: "`StorageUnit-ext-p_dispatch-upper` — an extendable unit dispatches at most the chosen build" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_dispatch <= StorageUnit_p_max_pu * StorageUnit_p_nom_ext + StorageUnit_ext_p_store_lower: + description: "`StorageUnit-ext-p_store-lower` — storing is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store >= 0 + StorageUnit_ext_p_store_upper: + description: >- + `StorageUnit-ext-p_store-upper` — an extendable unit stores at most the + chosen build, the minimum-per-unit column carrying that cap negated + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_p_store <= -StorageUnit_p_min_pu * StorageUnit_p_nom_ext + StorageUnit_ext_state_of_charge_lower: + description: "`StorageUnit-ext-state_of_charge-lower` — charge is non-negative" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge >= 0 + StorageUnit_ext_state_of_charge_upper: + description: "`StorageUnit-ext-state_of_charge-upper` — an extendable unit holds at most its hours at the chosen build" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_active + expression: StorageUnit_state_of_charge <= StorageUnit_max_hours * StorageUnit_p_nom_ext + StorageUnit_ext_p_nom_lower: + description: "`StorageUnit-ext-p_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable + expression: StorageUnit_p_nom_ext >= StorageUnit_p_nom_min + StorageUnit_ext_p_nom_upper: + description: "`StorageUnit-ext-p_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_max + expression: StorageUnit_p_nom_ext <= StorageUnit_p_nom_max + StorageUnit_p_nom_set: + description: "`StorageUnit-p_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, storage_unit] + where: StorageUnit_p_nom_extendable AND StorageUnit_p_nom_set + expression: StorageUnit_p_nom_ext == StorageUnit_p_nom_set + StorageUnit_energy_balance: + description: >- + `StorageUnit-energy_balance` — the charge carried in, plus what is + stored after its efficiency, less what dispatch draws down before its + own, plus inflow not spilled + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_active + expression: >- + StorageUnit_state_of_charge == + StorageUnit_charge_carried_in + + StorageUnit_efficiency_store * StorageUnit_p_store * snapshot_weightings_stores + - StorageUnit_p_dispatch * snapshot_weightings_stores / StorageUnit_efficiency_dispatch + + (StorageUnit_inflow - StorageUnit_spill) * snapshot_weightings_stores + StorageUnit_p_set: + description: "`StorageUnit-p_set` — net dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_set AND StorageUnit_active + expression: StorageUnit_p_dispatch - StorageUnit_p_store == StorageUnit_p_set + StorageUnit_p_dispatch_set: + description: "`StorageUnit-p_dispatch_set` — dispatch pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_dispatch_set AND StorageUnit_active + expression: StorageUnit_p_dispatch == StorageUnit_p_dispatch_set + StorageUnit_p_store_set: + description: "`StorageUnit-p_store_set` — charging pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_p_store_set AND StorageUnit_active + expression: StorageUnit_p_store == StorageUnit_p_store_set + StorageUnit_state_of_charge_set: + description: "`StorageUnit-state_of_charge_set` — charge pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, storage_unit] + where: StorageUnit_state_of_charge_set AND StorageUnit_active + expression: StorageUnit_state_of_charge == StorageUnit_state_of_charge_set + +assumptions: + StorageUnit_stands_in_one_run: + holds: "count(StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a storage unit stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic storage unit reaches back + `StorageUnit_inactive_snapshots` further to the last of them + StorageUnit_opens_late_only_where_it_opens: + holds: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "StorageUnit_opens_late" + description: >- + `StorageUnit_opens_late` marks the first snapshot the storage unit stands in, past + the first of the horizon, and no other + StorageUnit_opens_late_where_it_opens: + holds: "StorageUnit_opens_late" + where: "StorageUnit_active AND NOT shift(StorageUnit_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a storage unit that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `StorageUnit_opens_late` marks the snapshot + StorageUnit_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "StorageUnit_primary_energy_weight AND StorageUnit_state_of_charge_initial_per_period" + description: >- + PyPSA reads the closing charge of a unit that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:474`) + StorageUnit_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "StorageUnit_primary_energy_weight AND NOT StorageUnit_state_of_charge_initial_per_period" + description: >- + a unit that carries its charge from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:448`) + StorageUnit_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "StorageUnit_operational_limit_weight AND NOT StorageUnit_state_of_charge_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:647`) + StorageUnit_marginal_cost_quadratic_without_risk_preference: + holds: "StorageUnit_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * StorageUnit_p_nom_ext) * StorageUnit_capital_cost) * StorageUnit_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{on}^{h}`$ | `StorageUnit_active` over $`\mathcal{T} \times \mathcal{S}`$ — whether a storage unit stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{h}`$ | `StorageUnit_capital_weight` over $`\mathcal{S}`$ — the sum of period weights a storage unit stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}^{h}`$ | `StorageUnit_first_active` over $`\mathcal{Y} \times \mathcal{S}`$ — one in the first period a storage unit stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a storage unit that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\underline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_min` over $`\Xi \times \mathcal{S}`$ — least nominal power an extendable storage unit may be built at | +| $`\overline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_max` over $`\Xi \times \mathcal{S}`$ — most nominal power an extendable storage unit may be built at | +| $`\mathrm{c}^{\mathrm{cap},h}`$ | `StorageUnit_capital_cost` over $`\Xi \times \mathcal{S}`$ — cost of one unit of nominal power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$ — a given nominal power for an extendable storage unit; one without a value has no row here | +| $`\mathrm{h}^{\mathrm{nom}}`$ | `StorageUnit_p_nom` over $`\Xi \times \mathcal{S}`$ — nominal power | +| $`\mathrm{ext}^{h}`$ | `StorageUnit_p_nom_extendable` over $`\mathcal{S}`$ — whether the nominal power is a decision | +| $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | +| $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | +| $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | +| $`\eta^{-}`$ | `StorageUnit_efficiency_store` over $`\Xi \times \mathcal{S}`$ — share of the power drawn from the bus that becomes charge | +| $`\eta^{+}`$ | `StorageUnit_efficiency_dispatch` over $`\Xi \times \mathcal{S}`$ — share of the charge drawn down that reaches the bus | +| $`\mathrm{sgn}^{h}`$ | `StorageUnit_sign` over $`\mathcal{S}`$ — the sign net dispatch enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | +| $`\rho`$ | `StorageUnit_retention` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — share of charge kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | +| $`\mathrm{inflow}`$ | `StorageUnit_inflow` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — energy arriving per hour, a river into a reservoir | +| $`\mathrm{soc}^{0}`$ | `StorageUnit_state_of_charge_initial` over $`\Xi \times \mathcal{S}`$ — charge held before the first snapshot | +| $`\mathrm{cyc}`$ | `StorageUnit_cyclic_state_of_charge` over $`\Xi \times \mathcal{S}`$ — whether the horizon closes on itself instead of opening on the initial charge | +| $`\mathrm{cyc}^{y}`$ | `StorageUnit_cyclic_state_of_charge_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period closes on itself instead of carrying its charge on to the next; it overrides `cyclic_state_of_charge` and `state_of_charge_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}`$ | `StorageUnit_state_of_charge_initial_per_period` over $`\Xi \times \mathcal{S}`$ — whether each investment period opens on the initial charge instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{open}`$ | `StorageUnit_opens_late` over $`\mathcal{T} \times \mathcal{S}`$ — whether a snapshot is the first a storage unit stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots it stands in, past the first snapshot, data prep; false in a run where every unit stands throughout | +| $`\mathrm{idle}`$ | `StorageUnit_inactive_snapshots` over $`\mathcal{S}`$ — how many snapshots a storage unit does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic unit reaches back this many snapshots further, so it closes on the last snapshot it stands in | +| $`\mathrm{c}^{h}`$ | `StorageUnit_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of dispatch | +| $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | +| $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | +| $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | +| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | +| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | +| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | +| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | +| $`h^{-}`$ | `StorageUnit_p_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | +| $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | +| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | +| $`H`$ | `StorageUnit_p_nom_ext` over $`\mathcal{S}`$ — `StorageUnit-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$, data another file declares | +| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$, an expression another file defines | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression this file adds `StorageUnit_primary_energy` to | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression this file adds `StorageUnit_operational_limit` to | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `StorageUnit_tech_capacity_expansion` to | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `StorageUnit_opex` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `StorageUnit_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `StorageUnit_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{\mathit{soc}}`$ | `StorageUnit_charge_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — the charge a unit opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial charge, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A unit built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | +| $`\mathit{w}^{h}`$ | `StorageUnit_closing_weight` over $`\Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{S}`$ — what the charge a unit holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the unit reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{StorageUnit\_primary\_energy}`$ | `StorageUnit_primary_energy` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{StorageUnit\_operational\_limit}`$ | `StorageUnit_operational_limit` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{StorageUnit\_tech\_capacity\_expansion}`$ | `StorageUnit_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{StorageUnit\_opex}`$ | `StorageUnit_opex` over $`\Xi`$ | +| $`\mathit{StorageUnit\_additions}`$ | `StorageUnit_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{StorageUnit\_injection}`$ | `StorageUnit_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +$`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. + +$`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} +``` + +#### Subject to + +**`StorageUnit_fix_p_dispatch_lower`** + +```math +h^{+}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_fix_p_dispatch_upper`** + +```math +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{\xi,t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_fix_p_store_lower`** + +```math +h^{-}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_fix_p_store_upper`** + +```math +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{\xi,t,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_fix_state_of_charge_lower`** + +```math +\mathit{soc}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_fix_state_of_charge_upper`** + +```math +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{\xi,s} \cdot \mathrm{h}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \neg \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_p_dispatch_lower`** + +```math +h^{+}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_p_dispatch_upper`** + +```math +h^{+}_{\xi,t,s} \le \overline{\mathrm{h}}_{\xi,t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_p_store_lower`** + +```math +h^{-}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_p_store_upper`** + +```math +h^{-}_{\xi,t,s} \le -\underline{\mathrm{h}}_{\xi,t,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_state_of_charge_lower`** + +```math +\mathit{soc}_{\xi,t,s} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_state_of_charge_upper`** + +```math +\mathit{soc}_{\xi,t,s} \le \mathrm{T}^{h}_{\xi,s} \cdot H_{s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_ext_p_nom_lower`** + +```math +H_{s} \ge \underline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +``` + +**`StorageUnit_ext_p_nom_upper`** + +```math +H_{s} \le \overline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \overline{\mathrm{h}}^{\mathrm{nom}}_{\xi,s} \text{ is defined} +``` + +**`StorageUnit_p_nom_set`** + +```math +H_{s} = \mathrm{h}^{\mathrm{nom,set}}_{\xi,s} \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} \wedge \mathrm{h}^{\mathrm{nom,set}}_{\xi,s} \text{ is defined} +``` + +**`StorageUnit_energy_balance`** + +```math +\mathit{soc}_{\xi,t,s} = \overleftarrow{\mathit{soc}}_{\xi,t,s} + \eta^{-}_{\xi,s} \cdot h^{-}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t} - \frac{h^{+}_{\xi,t,s} \cdot \mathrm{w}^{\mathrm{sto}}_{t}}{\eta^{+}_{\xi,s}} + \left( \mathrm{inflow}_{\xi,t,s} - \mathit{spill}_{\xi,t,s} \right) \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_p_set`** + +```math +h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} = \mathrm{h}^{\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_p_dispatch_set`** + +```math +h^{+}_{\xi,t,s} = \mathrm{h}^{+,\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{+,\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_p_store_set`** + +```math +h^{-}_{\xi,t,s} = \mathrm{h}^{-,\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{h}^{-,\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_state_of_charge_set`** + +```math +\mathit{soc}_{\xi,t,s} = \mathrm{soc}^{\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{soc}^{\mathrm{set}}_{\xi,t,s} \text{ is defined} \wedge \mathrm{on}^{h}_{t,s} +``` + +#### Definitions + +**`StorageUnit_charge_carried_in`** + +```math +\overleftarrow{\mathit{soc}}_{\xi,t,s} = \begin{cases} \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,\left( t \ominus \mathrm{idle} \right) \ominus 1,s} & \text{if } \mathrm{cyc}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \mathrm{soc}^{0}_{\xi,s} & \text{if } \neg \mathrm{cyc}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}_{t,s} \right) \\ \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,s} & \text{if } \mathrm{cyc}^{y}_{\xi,s} \\ \mathrm{soc}^{0}_{\xi,s} & \text{if } \mathrm{reset}_{\xi,s} \wedge \neg \mathrm{cyc}^{y}_{\xi,s} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho_{\xi,t,s} \cdot \mathit{soc}_{\xi,t - 1,s} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} +``` + +**`StorageUnit_closing_weight`** + +```math +\mathit{w}^{h}_{\xi,g,t,s} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}_{\xi,s} \wedge \mathrm{in}_{\xi,g,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{\xi,g,t} & \text{if } \neg \mathrm{reset}_{\xi,s} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T},\ s \in \mathcal{S} +``` + +**`StorageUnit_primary_energy`** + +```math +\mathit{StorageUnit\_primary\_energy}_{\xi,g} = -\left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,g,t,s} \cdot \mathrm{a}^{h}_{\xi,g,s} \right) \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`StorageUnit_operational_limit`** + +```math +\mathit{StorageUnit\_operational\_limit}_{\xi,g} = -\left( \sum_{s \in \mathcal{S}} \sum_{t \in \mathcal{T}} \mathit{soc}_{\xi,t,s} \cdot \mathit{w}^{h}_{\xi,g,t,s} \cdot \mathrm{b}^{h}_{\xi,g,s} \right) \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`StorageUnit_tech_capacity_expansion`** + +```math +\mathit{StorageUnit\_tech\_capacity\_expansion}_{g} = \sum_{s \in \mathcal{S}} H_{s} \cdot \mathrm{m}^{h}_{g,s} \qquad \forall\, g \in \mathcal{G} +``` + +**`StorageUnit_opex`** + +```math +\mathit{StorageUnit\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +**`StorageUnit_additions`** + +```math +\mathit{StorageUnit\_additions}_{y,i} = \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_carrier}(s) = i} H_{s} \cdot \mathrm{new}^{h}_{y,s} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`StorageUnit_injection`** + +```math +\mathit{StorageUnit\_injection}_{\xi,t,n} = \sum_{s \in \mathcal{S} \,:\, \mathrm{StorageUnit\_bus}(s) = n} \mathrm{sgn}^{h}_{s} \cdot \left( h^{+}_{\xi,t,s} - h^{-}_{\xi,t,s} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`StorageUnit_p_dispatch`** + +```math +h^{+}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_p_store`** + +```math +h^{-}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_state_of_charge`** + +```math +\mathit{soc}_{\xi,t,s} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_spill`** + +```math +0 \le \mathit{spill}_{\xi,t,s} \le \mathrm{inflow}_{\xi,t,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{inflow}_{\xi,t,s} > 0 \wedge \mathrm{on}^{h}_{t,s} +``` + +**`StorageUnit_p_nom_ext`** + +```math +H_{s} \in \mathbb{R} \qquad \forall\, s \in \mathcal{S} \,:\, \mathrm{ext}^{h}_{s} +``` + +#### Assumptions + +**`StorageUnit_stands_in_one_run`** + +```math +\lvert \{ t \in \mathcal{T} \,:\, \mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \} \rvert \le 1 \qquad \forall\, s \in \mathcal{S} +``` + +**`StorageUnit_opens_late_only_where_it_opens`** + +```math +\mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \wedge \mathrm{pos}(t) > 0 \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{open}_{t,s} +``` + +**`StorageUnit_opens_late_where_it_opens`** + +```math +\mathrm{open}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{on}^{h}_{t,s} \wedge \neg \mathrm{on}^{h}_{t - 1,s} \wedge \mathrm{pos}(t) > 0 +``` + +**`StorageUnit_primary_energy_per_period_closes_over_the_horizon`** + +```math +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{\xi,g,t} \} \rvert = 0 \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \mathrm{a}^{h}_{\xi,g,s} \text{ is defined} \wedge \mathrm{reset}_{\xi,s} +``` + +**`StorageUnit_primary_energy_carried_over_has_unit_years`** + +```math +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, \xi \in \Xi,\ s \in \mathcal{S},\ g \in \mathcal{G},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{h}_{\xi,g,s} \text{ is defined} \wedge \neg \mathrm{reset}_{\xi,s} +``` + +**`StorageUnit_operational_limit_carried_over_has_unit_years`** + +```math +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \mathrm{b}^{h}_{\xi,g,s} \text{ is defined} \wedge \neg \mathrm{reset}_{\xi,s} \wedge \mathrm{in}_{\xi,g,t} +``` + +**`StorageUnit_marginal_cost_quadratic_without_risk_preference`** + +```math +\mathrm{c}^{h,(2)}_{\xi,t,s} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \omega > 0 +``` + diff --git a/docs/examples/pypsa/store.md b/docs/examples/pypsa/store.md new file mode 100644 index 00000000..7d05f8f3 --- /dev/null +++ b/docs/examples/pypsa/store.md @@ -0,0 +1,643 @@ + + +# Stores + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Store`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + store: + description: pure energy stores, each on one bus + global_constraint: + description: PyPSA's `GlobalConstraint` rows, one label per declared limit + period: + description: investment periods — PyPSA's `investment_periods` + dtype: int + carrier: + description: energy carriers, what a growth limit is set per + +relations: + snapshot_period: + description: the investment period a snapshot falls in + key: snapshot + values: period + Store_carrier: + description: the carrier a store holds + key: store + values: carrier + Store_bus: + description: the bus a store sits on + key: store + values: bus + +parameters: + Store_active: + description: whether a store stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, store] + dtype: bool + Store_capital_weight: + description: the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [store] + Store_first_active: + description: >- + one in the first period a store stands in, zero elsewhere, data prep. + PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a store + that has retired in every later period (`global_constraints.py:276`, + PyPSA/PyPSA#1938) + dims: [period, store] + Store_e_nom_min: + description: least nominal capacity an extendable store may be built at + dims: [scenario, store] + Store_e_nom_max: + description: most nominal capacity an extendable store may be built at + dims: [scenario, store] + Store_capital_cost: + description: cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, store] + Store_e_nom_set: + description: a given nominal capacity for an extendable store; one without a value has no row here + dims: [scenario, store] + Store_e_nom: + description: nominal energy capacity + dims: [scenario, store] + Store_e_nom_extendable: + description: whether the nominal energy capacity is a decision + dims: [store] + dtype: bool + Store_e_min_pu: + description: least energy held, per unit of nominal capacity — negative for a store that may go short + dims: [scenario, snapshot, store] + Store_e_max_pu: + description: most energy held, per unit of nominal capacity + dims: [scenario, snapshot, store] + Store_sign: + description: >- + the sign the power a store delivers enters its bus's balance with — + PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by + scenario (`consistency.py:1187`) + dims: [store] + Store_retention: + description: share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep + dims: [scenario, snapshot, store] + Store_e_initial: + description: energy held before the first snapshot + dims: [scenario, store] + Store_e_cyclic: + description: whether the horizon closes on itself instead of opening on the initial energy + dims: [scenario, store] + dtype: bool + Store_e_cyclic_per_period: + description: >- + whether each investment period closes on itself instead of carrying its + energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. + PyPSA reads it only under `multi_investment_periods`, so data prep feeds + false otherwise + dims: [scenario, store] + dtype: bool + Store_e_initial_per_period: + description: >- + whether each investment period opens on the initial energy instead of + carrying the previous period's; PyPSA reads it only under + `multi_investment_periods`, so data prep feeds false otherwise + dims: [scenario, store] + dtype: bool + Store_opens_late: + description: >- + whether a snapshot is the first a store stands in, where that is not the + first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots + it stands in, past the first snapshot, data prep; false in a run where + every store stands throughout + dims: [snapshot, store] + dtype: bool + Store_inactive_snapshots: + description: >- + how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, + data prep. A cyclic store reaches back this many snapshots further, so it + closes on the last snapshot it stands in + dims: [store] + dtype: int + Store_marginal_cost: + description: cost of one unit of power delivered + dims: [scenario, snapshot, store] + Store_marginal_cost_quadratic: + description: cost of the square of the net power delivered, so charging costs as much as delivering + dims: [scenario, snapshot, store] + Store_marginal_cost_storage: + description: cost of one unit of energy held over one snapshot + dims: [scenario, snapshot, store] + Store_e_set: + description: a given energy schedule; a store without one has no row here + dims: [scenario, snapshot, store] + Store_p_set: + description: a given schedule of power delivered; a store without one has no row here + dims: [scenario, snapshot, store] + Store_primary_energy_weight: + description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row + dims: [scenario, global_constraint, store] + Store_operational_limit_weight: + description: one where the store is in the row's set — data prep; one outside it has no row + dims: [scenario, global_constraint, store] + Store_tech_capacity_weight: + description: >- + one where the store is in the row's carrier-and-bus set — data prep; one + outside it, or one that does not stand in the row's `investment_period`, + has no row + dims: [global_constraint, store] + +variables: + Store_e: + description: "`Store-e` — energy held at the end of a snapshot" + dims: [scenario, snapshot, store] + where: Store_active + Store_p: + description: "`Store-p` — power delivered to the bus; charging is negative" + dims: [scenario, snapshot, store] + where: Store_active + Store_e_nom_ext: + description: >- + `Store-e_nom` — nominal capacity where it is a decision; the parameter + of the same PyPSA name carries the fixed regime + dims: [store] + where: Store_e_nom_extendable + +given: + parameters: + snapshot_weightings_objective: { dims: [snapshot] } + scenario_weight: { dims: [scenario] } + CVaR_omega: { dims: [] } + period_weight_objective: { dims: [period] } + period_weight_years: { dims: [period] } + snapshot_weightings_stores: { dims: [snapshot] } + GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } + expressions: + GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } + primary_energy: { dims: [scenario, global_constraint], term: Store_primary_energy } + operational_limit: { dims: [scenario, global_constraint], term: Store_operational_limit } + tech_capacity_expansion: { dims: [global_constraint], term: Store_tech_capacity_expansion } + scenario_opex: { dims: [scenario], term: Store_opex } + Carrier_additions: { dims: [period, carrier], term: Store_additions } + Bus_injection: { dims: [scenario, snapshot, bus], term: Store_injection } + +expressions: + Store_energy_carried_in: + description: >- + the energy a store opens a snapshot with — at the first snapshot it + stands in, its last such snapshot's less standing loss where it is + cyclic and the given initial energy, which no standing loss has touched + yet, where it is not; the previous snapshot's less standing loss + otherwise. A store built in a later period opens in that period, and a + cyclic one that retires closes on its own last snapshot. Per period, the + same holds with each investment period as the horizon + dims: [scenario, snapshot, store] + cases: + cyclic: + when: >- + Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: >- + Store_retention + * shift(shift(Store_e, along=snapshot, offset=1, edge='wrap'), along=snapshot, offset=Store_inactive_snapshots, edge='wrap') + opening: + when: >- + NOT Store_e_cyclic AND NOT Store_e_cyclic_per_period AND NOT Store_e_initial_per_period + AND (position(snapshot) == 0 OR Store_opens_late) + expression: Store_e_initial + period_cyclic: + when: Store_e_cyclic_per_period + expression: Store_retention * shift(Store_e, along=snapshot, offset=1, edge='wrap', by=snapshot_period, within=period) + period_opening: + when: Store_e_initial_per_period AND NOT Store_e_cyclic_per_period AND position(snapshot, by=snapshot_period, within=period) == 0 + expression: Store_e_initial + otherwise: Store_retention * shift(Store_e, along=snapshot, offset=1) + Store_closing_weight: + description: >- + what the energy a store holds at a snapshot counts for in a row as its + closing level — the years of the period at the last snapshot of each + counted period where the store reopens per period, one at the last + counted snapshot where it does not, and nothing elsewhere + dims: [scenario, global_constraint, snapshot, store] + cases: + per_period: + when: Store_e_initial_per_period AND GlobalConstraint_counts_snapshot AND position(snapshot, by=snapshot_period, within=period) == -1 + expression: at(period_weight_years, by=snapshot_period, over=period, into=snapshot) + carried_over: + when: NOT Store_e_initial_per_period + expression: GlobalConstraint_snapshot_closes + otherwise: 0 + Store_primary_energy: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) + Store_operational_limit: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) + Store_tech_capacity_expansion: + expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + Store_opex: + expression: >- + sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + Store_additions: + expression: >- + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) + Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + +constraints: + Store_fix_e_lower: + description: "`Store-fix-e-lower` — a fixed store holds at least its floor" + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active + expression: Store_e >= Store_e_min_pu * Store_e_nom + Store_fix_e_upper: + description: "`Store-fix-e-upper` — a fixed store holds at most its nominal capacity" + dims: [scenario, snapshot, store] + where: not Store_e_nom_extendable AND Store_active + expression: Store_e <= Store_e_max_pu * Store_e_nom + Store_ext_e_lower: + description: "`Store-ext-e-lower` — an extendable store holds at least its floor of the chosen build" + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active + expression: Store_e >= Store_e_min_pu * Store_e_nom_ext + Store_ext_e_upper: + description: "`Store-ext-e-upper` — an extendable store holds at most the chosen build" + dims: [scenario, snapshot, store] + where: Store_e_nom_extendable AND Store_active + expression: Store_e <= Store_e_max_pu * Store_e_nom_ext + Store_ext_e_nom_lower: + description: "`Store-ext-e_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, store] + where: Store_e_nom_extendable + expression: Store_e_nom_ext >= Store_e_nom_min + Store_ext_e_nom_upper: + description: "`Store-ext-e_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, store] + where: Store_e_nom_extendable AND Store_e_nom_max + expression: Store_e_nom_ext <= Store_e_nom_max + Store_e_nom_set: + description: "`Store-e_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, store] + where: Store_e_nom_extendable AND Store_e_nom_set + expression: Store_e_nom_ext == Store_e_nom_set + Store_energy_balance: + description: "`Store-energy_balance` — the energy carried in, less what is delivered to the bus" + dims: [scenario, snapshot, store] + where: Store_active + expression: >- + Store_e == + Store_energy_carried_in + - Store_p * snapshot_weightings_stores + Store_e_set: + description: "`Store-e_set` — energy pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_e_set AND Store_active + expression: Store_e == Store_e_set + Store_p_set: + description: "`Store-p_set` — power delivered pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, store] + where: Store_p_set AND Store_active + expression: Store_p == Store_p_set + +assumptions: + Store_stands_in_one_run: + holds: "count(Store_active AND NOT shift(Store_active, along=snapshot, offset=1), over=snapshot) <= 1" + description: >- + the snapshots a store stands in are one unbroken run, as a build + year and a lifetime make them. The opening row holds at the first + of them only, and a cyclic store reaches back + `Store_inactive_snapshots` further to the last of them + Store_opens_late_only_where_it_opens: + holds: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + where: "Store_opens_late" + description: >- + `Store_opens_late` marks the first snapshot the store stands in, past + the first of the horizon, and no other + Store_opens_late_where_it_opens: + holds: "Store_opens_late" + where: "Store_active AND NOT shift(Store_active, along=snapshot, offset=1) AND position(snapshot) > 0" + description: >- + a store that opens past the first snapshot of the horizon opens on + its initial level, or its last level where it is cyclic, only where + `Store_opens_late` marks the snapshot + Store_primary_energy_per_period_closes_over_the_horizon: + holds: "count(NOT GlobalConstraint_counts_snapshot, over=snapshot) == 0" + where: "Store_primary_energy_weight AND Store_e_initial_per_period" + description: >- + PyPSA reads the closing energy of a store that reopens per period at + the last snapshot of every period, and fails on a `primary_energy` row + that names an `investment_period` (`global_constraints.py:526`) + Store_primary_energy_carried_over_has_unit_years: + holds: "period_weight_years == 1" + where: "Store_primary_energy_weight AND NOT Store_e_initial_per_period" + description: >- + a store that carries its energy from one period to the next closes + once, at the last counted snapshot, and no period's years weighs that + level — PyPSA refuses it where any period's years is not one + (`global_constraints.py:500`) + Store_operational_limit_carried_over_has_unit_years: + holds: "at(period_weight_years == 1, by=snapshot_period, over=period, into=snapshot)" + where: "Store_operational_limit_weight AND NOT Store_e_initial_per_period AND GlobalConstraint_counts_snapshot" + description: >- + the same for an `operational_limit` row, over the periods it counts — + PyPSA refuses it (`global_constraints.py:695`) + Store_marginal_cost_quadratic_without_risk_preference: + holds: "Store_marginal_cost_quadratic == 0" + where: "CVaR_omega > 0" + description: >- + a quadratic cost puts a square into every `CVaR-excess` row, and PyPSA + refuses quadratic costs under any risk preference + (`optimize.py:467-474`). The spec cannot tell no risk preference from + one with `omega = 0`, so it refuses only where `omega` is positive + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Store_e_nom_ext) * Store_capital_cost) * Store_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | +| $`\mathcal{G}`$ | index $`g`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | +| $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | +| $`\mathcal{I}`$ | index $`i`$ — `carrier` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I}`$ — energy carriers, what a growth limit is set per | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{on}^{e}`$ | `Store_active` over $`\mathcal{T} \times \mathcal{V}`$ — whether a store stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{e}`$ | `Store_capital_weight` over $`\mathcal{V}`$ — the sum of period weights a store stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere, data prep. PyPSA `1.3.0` takes `active.cumsum() == 1`, which also counts a store that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | +| $`\underline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_min` over $`\Xi \times \mathcal{V}`$ — least nominal capacity an extendable store may be built at | +| $`\overline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_max` over $`\Xi \times \mathcal{V}`$ — most nominal capacity an extendable store may be built at | +| $`\mathrm{c}^{\mathrm{cap},e}`$ | `Store_capital_cost` over $`\Xi \times \mathcal{V}`$ — cost of one unit of nominal capacity — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$ — a given nominal capacity for an extendable store; one without a value has no row here | +| $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\Xi \times \mathcal{V}`$ — nominal energy capacity | +| $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | +| $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | +| $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | +| $`\mathrm{sgn}^{q}`$ | `Store_sign` over $`\mathcal{V}`$ — the sign the power a store delivers enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | +| $`\rho^{e}`$ | `Store_retention` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — share of energy kept over a snapshot — PyPSA's `(1 - standing_loss) ** elapsed hours`, data prep | +| $`\mathrm{e}^{0}`$ | `Store_e_initial` over $`\Xi \times \mathcal{V}`$ — energy held before the first snapshot | +| $`\mathrm{cyc}^{e}`$ | `Store_e_cyclic` over $`\Xi \times \mathcal{V}`$ — whether the horizon closes on itself instead of opening on the initial energy | +| $`\mathrm{cyc}^{e,y}`$ | `Store_e_cyclic_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period closes on itself instead of carrying its energy on to the next; it overrides `e_cyclic` and `e_initial_per_period`. PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{reset}^{e}`$ | `Store_e_initial_per_period` over $`\Xi \times \mathcal{V}`$ — whether each investment period opens on the initial energy instead of carrying the previous period's; PyPSA reads it only under `multi_investment_periods`, so data prep feeds false otherwise | +| $`\mathrm{open}^{e}`$ | `Store_opens_late` over $`\mathcal{T} \times \mathcal{V}`$ — whether a snapshot is the first a store stands in, where that is not the first of the horizon — PyPSA's `active.cumsum() == 1` over the snapshots it stands in, past the first snapshot, data prep; false in a run where every store stands throughout | +| $`\mathrm{idle}^{e}`$ | `Store_inactive_snapshots` over $`\mathcal{V}`$ — how many snapshots a store does not stand in — PyPSA's `(~active).sum()`, data prep. A cyclic store reaches back this many snapshots further, so it closes on the last snapshot it stands in | +| $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | +| $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | +| $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | +| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | +| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | +| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | +| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`e`$ | `Store_e` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-e` — energy held at the end of a snapshot | +| $`q`$ | `Store_p` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-p` — power delivered to the bus; charging is negative | +| $`E`$ | `Store_e_nom_ext` over $`\mathcal{V}`$ — `Store-e_nom` — nominal capacity where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$, data another file declares | +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\omega`$ | `CVaR_omega` (scalar), data another file declares | +| $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$, data another file declares | +| $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$, data another file declares | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$, data another file declares | +| $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$, an expression another file defines | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Store_primary_energy` to | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Store_operational_limit` to | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Store_tech_capacity_expansion` to | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Store_opex` to | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Store_additions` to | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Store_injection` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\overleftarrow{e}`$ | `Store_energy_carried_in` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — the energy a store opens a snapshot with — at the first snapshot it stands in, its last such snapshot's less standing loss where it is cyclic and the given initial energy, which no standing loss has touched yet, where it is not; the previous snapshot's less standing loss otherwise. A store built in a later period opens in that period, and a cyclic one that retires closes on its own last snapshot. Per period, the same holds with each investment period as the horizon | +| $`\mathit{w}^{e}`$ | `Store_closing_weight` over $`\Xi \times \mathcal{G} \times \mathcal{T} \times \mathcal{V}`$ — what the energy a store holds at a snapshot counts for in a row as its closing level — the years of the period at the last snapshot of each counted period where the store reopens per period, one at the last counted snapshot where it does not, and nothing elsewhere | +| $`\mathit{Store\_primary\_energy}`$ | `Store_primary_energy` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Store\_operational\_limit}`$ | `Store_operational_limit` over $`\Xi \times \mathcal{G}`$ | +| $`\mathit{Store\_tech\_capacity\_expansion}`$ | `Store_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{Store\_opex}`$ | `Store_opex` over $`\Xi`$ | +| $`\mathit{Store\_additions}`$ | `Store_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | +| $`\mathit{Store\_injection}`$ | `Store_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | + +$`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size of the dimension (`roll`). Plain $`t-k`$ (`shift`) has no wraparound — terms translated past the edge are simply absent. + +$`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} +``` + +#### Subject to + +**`Store_fix_e_lower`** + +```math +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{\xi,t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +``` + +**`Store_fix_e_upper`** + +```math +e_{\xi,t,v} \le \overline{\mathrm{e}}_{\xi,t,v} \cdot \mathrm{e}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \neg \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +``` + +**`Store_ext_e_lower`** + +```math +e_{\xi,t,v} \ge \underline{\mathrm{e}}_{\xi,t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +``` + +**`Store_ext_e_upper`** + +```math +e_{\xi,t,v} \le \overline{\mathrm{e}}_{\xi,t,v} \cdot E_{v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{on}^{e}_{t,v} +``` + +**`Store_ext_e_nom_lower`** + +```math +E_{v} \ge \underline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} +``` + +**`Store_ext_e_nom_upper`** + +```math +E_{v} \le \overline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \overline{\mathrm{e}}^{\mathrm{nom}}_{\xi,v} \text{ is defined} +``` + +**`Store_e_nom_set`** + +```math +E_{v} = \mathrm{e}^{\mathrm{nom,set}}_{\xi,v} \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} \wedge \mathrm{e}^{\mathrm{nom,set}}_{\xi,v} \text{ is defined} +``` + +**`Store_energy_balance`** + +```math +e_{\xi,t,v} = \overleftarrow{e}_{\xi,t,v} - q_{\xi,t,v} \cdot \mathrm{w}^{\mathrm{sto}}_{t} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} +``` + +**`Store_e_set`** + +```math +e_{\xi,t,v} = \mathrm{e}^{\mathrm{set}}_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{e}^{\mathrm{set}}_{\xi,t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} +``` + +**`Store_p_set`** + +```math +q_{\xi,t,v} = \mathrm{q}^{\mathrm{set}}_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{q}^{\mathrm{set}}_{\xi,t,v} \text{ is defined} \wedge \mathrm{on}^{e}_{t,v} +``` + +#### Definitions + +**`Store_energy_carried_in`** + +```math +\overleftarrow{e}_{\xi,t,v} = \begin{cases} \rho^{e}_{\xi,t,v} \cdot e_{\xi,\left( t \ominus \mathrm{idle}^{e} \right) \ominus 1,v} & \text{if } \mathrm{cyc}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \mathrm{e}^{0}_{\xi,v} & \text{if } \neg \mathrm{cyc}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \left( \mathrm{pos}(t) = 0 \vee \mathrm{open}^{e}_{t,v} \right) \\ \rho^{e}_{\xi,t,v} \cdot e_{\xi,t \ominus^{\mathrm{snapshot\_period}(t)} 1,v} & \text{if } \mathrm{cyc}^{e,y}_{\xi,v} \\ \mathrm{e}^{0}_{\xi,v} & \text{if } \mathrm{reset}^{e}_{\xi,v} \wedge \neg \mathrm{cyc}^{e,y}_{\xi,v} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = 0 \\ \rho^{e}_{\xi,t,v} \cdot e_{\xi,t - 1,v} & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} +``` + +**`Store_closing_weight`** + +```math +\mathit{w}^{e}_{\xi,g,t,v} = \begin{cases} \mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} & \text{if } \mathrm{reset}^{e}_{\xi,v} \wedge \mathrm{in}_{\xi,g,t} \wedge \mathrm{pos}_{\mathrm{snapshot\_period}(t)}(t) = \lvert \mathcal{T}_{\mathrm{snapshot\_period}(t)} \rvert - 1 \\ \mathit{last}_{\xi,g,t} & \text{if } \neg \mathrm{reset}^{e}_{\xi,v} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T},\ v \in \mathcal{V} +``` + +**`Store_primary_energy`** + +```math +\mathit{Store\_primary\_energy}_{\xi,g} = -\left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,g,t,v} \cdot \mathrm{a}^{e}_{\xi,g,v} \right) \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Store_operational_limit`** + +```math +\mathit{Store\_operational\_limit}_{\xi,g} = -\left( \sum_{v \in \mathcal{V}} \sum_{t \in \mathcal{T}} e_{\xi,t,v} \cdot \mathit{w}^{e}_{\xi,g,t,v} \cdot \mathrm{b}^{e}_{\xi,g,v} \right) \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`Store_tech_capacity_expansion`** + +```math +\mathit{Store\_tech\_capacity\_expansion}_{g} = \sum_{v \in \mathcal{V}} E_{v} \cdot \mathrm{m}^{e}_{g,v} \qquad \forall\, g \in \mathcal{G} +``` + +**`Store_opex`** + +```math +\mathit{Store\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi +``` + +**`Store_additions`** + +```math +\mathit{Store\_additions}_{y,i} = \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_carrier}(v) = i} E_{v} \cdot \mathrm{new}^{e}_{y,v} \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`Store_injection`** + +```math +\mathit{Store\_injection}_{\xi,t,n} = \sum_{v \in \mathcal{V} \,:\, \mathrm{Store\_bus}(v) = n} \mathrm{sgn}^{q}_{v} \cdot q_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Store_e`** + +```math +e_{\xi,t,v} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} +``` + +**`Store_p`** + +```math +q_{\xi,t,v} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} +``` + +**`Store_e_nom_ext`** + +```math +E_{v} \in \mathbb{R} \qquad \forall\, v \in \mathcal{V} \,:\, \mathrm{ext}^{e}_{v} +``` + +#### Assumptions + +**`Store_stands_in_one_run`** + +```math +\lvert \{ t \in \mathcal{T} \,:\, \mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \} \rvert \le 1 \qquad \forall\, v \in \mathcal{V} +``` + +**`Store_opens_late_only_where_it_opens`** + +```math +\mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \wedge \mathrm{pos}(t) > 0 \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{open}^{e}_{t,v} +``` + +**`Store_opens_late_where_it_opens`** + +```math +\mathrm{open}^{e}_{t,v} \qquad \forall\, t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \mathrm{on}^{e}_{t,v} \wedge \neg \mathrm{on}^{e}_{t - 1,v} \wedge \mathrm{pos}(t) > 0 +``` + +**`Store_primary_energy_per_period_closes_over_the_horizon`** + +```math +\lvert \{ t \in \mathcal{T} \,:\, \neg \mathrm{in}_{\xi,g,t} \} \rvert = 0 \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V},\ g \in \mathcal{G} \,:\, \mathrm{a}^{e}_{\xi,g,v} \text{ is defined} \wedge \mathrm{reset}^{e}_{\xi,v} +``` + +**`Store_primary_energy_carried_over_has_unit_years`** + +```math +\mathrm{w}^{\mathrm{yr}}_{y} = 1 \qquad \forall\, \xi \in \Xi,\ v \in \mathcal{V},\ g \in \mathcal{G},\ y \in \mathcal{Y} \,:\, \mathrm{a}^{e}_{\xi,g,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{\xi,v} +``` + +**`Store_operational_limit_carried_over_has_unit_years`** + +```math +\mathrm{w}^{\mathrm{yr}}_{\mathrm{snapshot\_period}(t)} = 1 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V},\ g \in \mathcal{G} \,:\, \mathrm{b}^{e}_{\xi,g,v} \text{ is defined} \wedge \neg \mathrm{reset}^{e}_{\xi,v} \wedge \mathrm{in}_{\xi,g,t} +``` + +**`Store_marginal_cost_quadratic_without_risk_preference`** + +```math +\mathrm{c}^{q,(2)}_{\xi,t,v} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ v \in \mathcal{V} \,:\, \omega > 0 +``` + diff --git a/docs/examples/pypsa/transformer.md b/docs/examples/pypsa/transformer.md new file mode 100644 index 00000000..19f2b940 --- /dev/null +++ b/docs/examples/pypsa/transformer.md @@ -0,0 +1,447 @@ + + +# Transformers + +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Transformer`. It adds a term to `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). + + +```yaml +dimensions: + scenario: + description: the futures dispatch is chosen in, each with a weight + snapshot: + description: dispatch periods + dtype: datetime + bus: + description: network nodes + transformer: + description: passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised + cycle: + description: independent cycles of the passive network graph — the cycle basis, data prep + segment: + description: >- + the cuts a passive branch's loss curve is held above — PyPSA's tangents, + as many as its `segments` count, or its secants, as many as its tolerance + loop places; none in a lossless run + dtype: int + +relations: + Transformer_bus0: + description: the bus a transformer's flow is measured at + key: transformer + values: bus + Transformer_bus1: + description: the bus at a transformer's other end + key: transformer + values: bus + +parameters: + Transformer_active: + description: whether a transformer stands in a snapshot's period — PyPSA's `active`, data prep + dims: [snapshot, transformer] + dtype: bool + Transformer_capital_weight: + description: the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep + dims: [transformer] + Transformer_s_nom: + description: nominal apparent power + dims: [scenario, transformer] + Transformer_s_nom_extendable: + description: whether the nominal apparent power is a decision + dims: [transformer] + dtype: bool + Transformer_s_max_pu: + description: most flow either way, per unit of nominal apparent power + dims: [scenario, snapshot, transformer] + Transformer_s_nom_min: + description: least nominal apparent power an extendable transformer may be built at + dims: [scenario, transformer] + Transformer_s_nom_max: + description: most nominal apparent power an extendable transformer may be built at + dims: [scenario, transformer] + Transformer_capital_cost: + description: cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep + dims: [scenario, transformer] + Transformer_s_nom_set: + description: a given nominal apparent power for an extendable transformer; one without a value has no row here + dims: [scenario, transformer] + Transformer_s_set: + description: a given flow schedule; a transformer without one has no row here + dims: [scenario, snapshot, transformer] + Transformer_cycle_weight: + description: >- + the transformer's effective series reactance, `x` times its tap ratio, + signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle + basis, data prep; a transformer in no cycle has no row. From the first + scenario only, as a line's + dims: [transformer, cycle] + Transformer_phase_shift_weight: + description: >- + a fixed transformer's phase shift in radians, signed by its orientation + in the cycle — a constant added to the cycle sum, data prep; zero for a + varying transformer, whose shift is a decision instead, so the constant and + the variable term never both count a shift. A transformer with no shift or + in no cycle has no row + dims: [transformer, cycle] + Transformer_phase_shift_varying: + description: >- + whether a transformer's phase shift is a decision — PyPSA's + `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a + fixed shift carried by `phase_shift`. The shift parameters carry no + scenario: only a cycle row reads them, and PyPSA fails on a transformer in + a cycle on a network with scenarios (`constraints.py:1654`) + dims: [transformer] + dtype: bool + Transformer_phase_shift_min: + description: >- + the least a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is + a decision, otherwise the transformer keeps its fixed `phase_shift` + dims: [transformer] + Transformer_phase_shift_max: + description: >- + the most a varying transformer's phase shift may take, in degrees — + PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer + dims: [transformer] + Transformer_phase_shift_cycle_weight: + description: >- + the cycle sign for a varying transformer's phase shift, times π/180 so a + shift in degrees enters the cycle sum in radians — data prep; zero for a + fixed transformer or one in no cycle + dims: [transformer, cycle] + Transformer_loss_max: + description: >- + the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * + s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times + the tap ratio, data prep + dims: [scenario, snapshot, transformer] + Transformer_loss_slope: + description: >- + the slope of a cut to a transformer's loss curve — a tangent's + `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, + over the transformer's own `r_pu_eff` and rating, data prep + dims: [scenario, snapshot, transformer, segment] + Transformer_loss_offset: + description: >- + where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, + a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep + dims: [scenario, snapshot, transformer, segment] + +variables: + Transformer_s: + description: >- + `Transformer-s` — PyPSA's `p0`, the flow measured at the + `Transformer_bus0` end: a positive value withdraws there and injects at + `Transformer_bus1`, lossless + dims: [scenario, snapshot, transformer] + where: Transformer_active + Transformer_loss: + description: >- + `Transformer-loss` — what a transformer dissipates carrying its flow, as + a line does; absent, and zero in the balance, where the network is + lossless + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + absence: zero + bounds: + lower: 0 + Transformer_phase_shift: + description: >- + `Transformer-phase_shift` — a phase-shifting transformer's voltage angle + shift in degrees, chosen per snapshot to redistribute the flows around its + cycles without moving active power; absent, and zero in the cycle sum, + where the shift is fixed + dims: [scenario, snapshot, transformer] + where: Transformer_phase_shift_varying AND Transformer_active + absence: zero + bounds: + lower: Transformer_phase_shift_min + upper: Transformer_phase_shift_max + Transformer_s_nom_ext: + description: >- + `Transformer-s_nom` — nominal apparent power where it is a decision; the + parameter of the same PyPSA name carries the fixed regime + dims: [transformer] + where: Transformer_s_nom_extendable + +given: + parameters: + scenario_weight: { dims: [scenario] } + transmission_losses: { dims: [], dtype: bool } + expressions: + Bus_injection: { dims: [scenario, snapshot, bus], term: Transformer_injection } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Transformer_angle_sum } + +expressions: + Transformer_s_monitored: + description: the flow a transformer's post-contingency rows read, as a line's + dims: [scenario, snapshot, transformer] + cases: + standing: { when: Transformer_active, expression: Transformer_s } + otherwise: 0 + Transformer_injection: + expression: >- + -sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) + - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) + Transformer_angle_sum: + expression: >- + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) + +constraints: + Transformer_fix_s_lower: + description: "`Transformer-fix-s-lower` — a fixed transformer carries at least the negative of its rating, the loss counted against it" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom + Transformer_fix_s_upper: + description: "`Transformer-fix-s-upper` — a fixed transformer carries at most its rating, the loss included" + dims: [scenario, snapshot, transformer] + where: not Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom + Transformer_ext_s_lower: + description: "`Transformer-ext-s-lower` — an extendable transformer carries at least the negative of its rating of the chosen build, the loss counted against it" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s - Transformer_loss >= -Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_upper: + description: "`Transformer-ext-s-upper` — an extendable transformer carries at most its rating of the chosen build, the loss included" + dims: [scenario, snapshot, transformer] + where: Transformer_s_nom_extendable AND Transformer_active + expression: Transformer_s + Transformer_loss <= Transformer_s_max_pu * Transformer_s_nom_ext + Transformer_ext_s_nom_lower: + description: "`Transformer-ext-s_nom-lower` — the chosen build is at least its floor in every scenario" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable + expression: Transformer_s_nom_ext >= Transformer_s_nom_min + Transformer_ext_s_nom_upper: + description: "`Transformer-ext-s_nom-upper` — the chosen build is at most its cap in every scenario; a cap of infinity is no row" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_max + expression: Transformer_s_nom_ext <= Transformer_s_nom_max + Transformer_s_nom_set: + description: "`Transformer-s_nom_set` — the chosen build pinned, wherever a value is given" + dims: [scenario, transformer] + where: Transformer_s_nom_extendable AND Transformer_s_nom_set + expression: Transformer_s_nom_ext == Transformer_s_nom_set + Transformer_s_set: + description: "`Transformer-s_set` — flow pinned to the given schedule, wherever one is given" + dims: [scenario, snapshot, transformer] + where: Transformer_s_set AND Transformer_active + expression: Transformer_s == Transformer_s_set + Transformer_loss_upper: + description: "`Transformer-loss_upper` — a transformer dissipates at most the loss at its rating" + dims: [scenario, snapshot, transformer] + where: transmission_losses AND Transformer_active + expression: Transformer_loss <= Transformer_loss_max + Transformer_loss_tangents_forward: + description: >- + `Transformer-loss_tangents-{k}-1`, `Transformer-loss_secants-pos` — the + loss sits above every cut to its curve for flow one way, as a line's + does, over the segment dimension + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss + Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + Transformer_loss_tangents_reverse: + description: >- + `Transformer-loss_tangents-{k}--1`, `Transformer-loss_secants-neg` — the + same fan mirrored, the loss depending on the flow's magnitude + dims: [scenario, snapshot, transformer, segment] + where: transmission_losses AND Transformer_active + expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset + +objective: + sense: minimize + expression: >- + sum(((scenario_weight * Transformer_s_nom_ext) * Transformer_capital_cost) * Transformer_capital_weight) +``` + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | +| $`\mathcal{S}`$ | index $`s`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{on}^{\sigma}`$ | `Transformer_active` over $`\mathcal{T} \times \mathcal{M}`$ — whether a transformer stands in a snapshot's period — PyPSA's `active`, data prep | +| $`\mathrm{W}^{\sigma}`$ | `Transformer_capital_weight` over $`\mathcal{M}`$ — the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep | +| $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\Xi \times \mathcal{M}`$ — nominal apparent power | +| $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | +| $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | +| $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\Xi \times \mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | +| $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\Xi \times \mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | +| $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\Xi \times \mathcal{M}`$ — cost of one unit of nominal apparent power — PyPSA's `capital_cost`, periodized as an annuity in data prep | +| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | +| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | +| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle has no row | +| $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift`. The shift parameters carry no scenario: only a cycle row reads them, and PyPSA fails on a transformer in a cycle on a network with scenarios (`constraints.py:1654`) | +| $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | +| $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | +| $`\mathrm{Transformer\_phase\_shift\_cycle\_weight}`$ | `Transformer_phase_shift_cycle_weight` over $`\mathcal{M} \times \mathcal{C}`$ — the cycle sign for a varying transformer's phase shift, times π/180 so a shift in degrees enters the cycle sum in radians — data prep; zero for a fixed transformer or one in no cycle | +| $`\overline{\ell}^{\sigma}`$ | `Transformer_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the loss at a transformer's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, its `r_pu_eff` the resistance over the given `s_nom` times the tap ratio, data prep | +| $`\mathrm{a}^{\sigma}`$ | `Transformer_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{M} \times \mathcal{S}`$ — the slope of a cut to a transformer's loss curve — a tangent's `2 * r_pu_eff * p_k`, a secant's `r_pu_eff * (p_k + p_k+1)`, as a line's, over the transformer's own `r_pu_eff` and rating, data prep | +| $`\mathrm{b}^{\sigma}`$ | `Transformer_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{M} \times \mathcal{S}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | +| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | +| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | +| $`\Sigma`$ | `Transformer_s_nom_ext` over $`\mathcal{M}`$ — `Transformer-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | +| $`\mathrm{lossy}`$ | `transmission_losses` (scalar), data another file declares | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Transformer_injection` to | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression this file adds `Transformer_angle_sum` to | + +#### Definitions + +| Symbol | Meaning | +|---|---| +| $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | +| $`\mathit{Transformer\_injection}`$ | `Transformer_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | +| $`\mathit{Transformer\_angle\_sum}`$ | `Transformer_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | + +Upright is what the data supplies — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +#### Objective + +```math +\min \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} +``` + +#### Subject to + +**`Transformer_fix_s_lower`** + +```math +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_fix_s_upper`** + +```math +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{\xi,t,m} \cdot \sigma^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \neg \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_ext_s_lower`** + +```math +\sigma_{\xi,t,m} - \ell^{\sigma}_{\xi,t,m} \ge -\overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_ext_s_upper`** + +```math +\sigma_{\xi,t,m} + \ell^{\sigma}_{\xi,t,m} \le \overline{\sigma}_{\xi,t,m} \cdot \Sigma_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_ext_s_nom_lower`** + +```math +\Sigma_{m} \ge \underline{\sigma}^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} +``` + +**`Transformer_ext_s_nom_upper`** + +```math +\Sigma_{m} \le \overline{\sigma}^{\mathrm{nom}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \overline{\sigma}^{\mathrm{nom}}_{\xi,m} \text{ is defined} +``` + +**`Transformer_s_nom_set`** + +```math +\Sigma_{m} = \sigma^{\mathrm{nom,set}}_{\xi,m} \qquad \forall\, \xi \in \Xi,\ m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} \wedge \sigma^{\mathrm{nom,set}}_{\xi,m} \text{ is defined} +``` + +**`Transformer_s_set`** + +```math +\sigma_{\xi,t,m} = \sigma^{\mathrm{set}}_{\xi,t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \sigma^{\mathrm{set}}_{\xi,t,m} \text{ is defined} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_loss_upper`** + +```math +\ell^{\sigma}_{\xi,t,m} \le \overline{\ell}^{\sigma}_{\xi,t,m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_loss_tangents_forward`** + +```math +\ell^{\sigma}_{\xi,t,m} + \mathrm{a}^{\sigma}_{\xi,t,m,s} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{\xi,t,m,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ s \in \mathcal{S} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_loss_tangents_reverse`** + +```math +\ell^{\sigma}_{\xi,t,m} - \mathrm{a}^{\sigma}_{\xi,t,m,s} \cdot \sigma_{\xi,t,m} \ge \mathrm{b}^{\sigma}_{\xi,t,m,s} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M},\ s \in \mathcal{S} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +#### Definitions + +**`Transformer_s_monitored`** + +```math +\check{\sigma}_{\xi,t,m} = \begin{cases} \sigma_{\xi,t,m} & \text{if } \mathrm{on}^{\sigma}_{t,m} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} +``` + +**`Transformer_injection`** + +```math +\mathit{Transformer\_injection}_{\xi,t,n} = -\left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \sigma_{\xi,t,m} \right) + \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \sigma_{\xi,t,m} - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus0}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) - 0.5 \cdot \left( \sum_{m \in \mathcal{M} \,:\, \mathrm{Transformer\_bus1}(m) = n} \ell^{\sigma}_{\xi,t,m} \right) \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` + +**`Transformer_angle_sum`** + +```math +\mathit{Transformer\_angle\_sum}_{\xi,t,c} = \sum_{m \in \mathcal{M}} \sigma_{\xi,t,m} \cdot \mathrm{x}^{\sigma}_{m,c} + \sum_{m \in \mathcal{M}} \vartheta_{m,c} + \sum_{m \in \mathcal{M}} \mathit{Transformer\_phase\_shift}_{\xi,t,m} \cdot \mathrm{Transformer\_phase\_shift\_cycle\_weight}_{m,c} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` + +#### Variable domains + +**`Transformer_s`** + +```math +\sigma_{\xi,t,m} \in \mathbb{R} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_loss`** + +```math +\ell^{\sigma}_{\xi,t,m} \ge 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{lossy} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_phase_shift`** + +```math +\mathrm{Transformer\_phase\_shift\_min}_{m} \le \mathit{Transformer\_phase\_shift}_{\xi,t,m} \le \mathrm{Transformer\_phase\_shift\_max}_{m} \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ m \in \mathcal{M} \,:\, \mathrm{Transformer\_phase\_shift\_varying}_{m} \wedge \mathrm{on}^{\sigma}_{t,m} +``` + +**`Transformer_s_nom_ext`** + +```math +\Sigma_{m} \in \mathbb{R} \qquad \forall\, m \in \mathcal{M} \,:\, \mathrm{ext}^{\sigma}_{m} +``` + diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 4c898373..513971c3 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -2061,83 +2061,166 @@ expressions: when: NOT Store_e_initial_per_period expression: GlobalConstraint_snapshot_closes otherwise: 0 + Generator_primary_energy: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + StorageUnit_primary_energy: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + Store_primary_energy: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) primary_energy: + expression: Generator_primary_energy + StorageUnit_primary_energy + Store_primary_energy description: >- what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant + Generator_operational_limit: expression: >- - sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_primary_energy_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * Store_closing_weight * Store_primary_energy_weight, over=snapshot), over=store) + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + StorageUnit_operational_limit: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + Store_operational_limit: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) operational_limit: + expression: >- + Generator_operational_limit + + StorageUnit_operational_limit + + Store_operational_limit description: >- what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant - expression: >- - sum(sum(Generator_p * GlobalConstraint_energy_weight * Generator_operational_limit_weight, over=snapshot), over=generator) - - sum(sum(StorageUnit_state_of_charge * StorageUnit_closing_weight * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) - - sum(sum(Store_e * Store_closing_weight * Store_operational_limit_weight, over=snapshot), over=store) + Line_transmission_volume_expansion: + expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) + Link_transmission_volume_expansion: + expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) transmission_volume_expansion: - description: what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches - expression: >- - sum(Line_s_nom_ext * Line_volume_weight, over=line) - + sum(Link_p_nom_ext * Link_volume_weight, over=link) + expression: Line_transmission_volume_expansion + Link_transmission_volume_expansion + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + Line_transmission_expansion_cost: + expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + Link_transmission_expansion_cost: + expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) transmission_expansion_cost: - description: what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches - expression: >- - sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) - + sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + expression: Line_transmission_expansion_cost + Link_transmission_expansion_cost + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches + Generator_tech_capacity_expansion: + expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + Line_tech_capacity_expansion: + expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + Link_tech_capacity_expansion: + expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) + Process_tech_capacity_expansion: + expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + StorageUnit_tech_capacity_expansion: + expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + Store_tech_capacity_expansion: + expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) tech_capacity_expansion: - description: what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set expression: >- - sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) - + sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) - + sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) - + sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) - + sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) - + sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) - scenario_opex: - description: what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) + Generator_tech_capacity_expansion + + Line_tech_capacity_expansion + + Link_tech_capacity_expansion + + Process_tech_capacity_expansion + + StorageUnit_tech_capacity_expansion + + Store_tech_capacity_expansion + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + Generator_opex: + expression: >- + sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + Generator_commitment_opex: expression: >- - sum(sum(Generator_p * Generator_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Generator_p * Generator_p * Generator_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) - + sum(sum(Link_p * Link_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Link_p * Link_p * Link_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) - + sum(sum(Process_p * Process_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(Process_p * Process_p * Process_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) - + sum(sum(StorageUnit_p_dispatch * StorageUnit_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_p_dispatch * StorageUnit_p_dispatch * StorageUnit_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(StorageUnit_spill * StorageUnit_spill_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) - + sum(sum(Store_p * Store_marginal_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Store_p * Store_p * Store_marginal_cost_quadratic * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Store_e * Store_marginal_cost_storage * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) - + sum(sum(Generator_status * Generator_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) - + sum(sum(Link_status * Link_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + Link_opex: + expression: >- + sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + Link_commitment_opex: + expression: >- + sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) - + sum(sum(Process_status * Process_stand_by_cost * snapshot_weightings_objective * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + Process_opex: + expression: >- + sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + Process_commitment_opex: + expression: >- + sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) + StorageUnit_opex: + expression: >- + sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + Store_opex: + expression: >- + sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + scenario_opex: + expression: >- + Generator_opex + + Generator_commitment_opex + + Link_opex + + Link_commitment_opex + + Process_opex + + Process_commitment_opex + + StorageUnit_opex + + Store_opex + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + Generator_additions: + expression: >- + sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + Line_additions: + expression: >- + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + Link_additions: + expression: >- + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + Process_additions: + expression: >- + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + StorageUnit_additions: + expression: >- + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + Store_additions: + expression: >- + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) Carrier_additions: + expression: >- + Generator_additions + + Line_additions + + Link_additions + + Process_additions + + StorageUnit_additions + + Store_additions description: >- what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like PyPSA, - it sums only the components that carry a carrier attribute, so a + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier - expression: >- - sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - + sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) - + sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) - + sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) - + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) - + sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) Carrier_relative_growth: description: >- the share of the previous period's additions a carrier's growth limit @@ -2180,6 +2263,58 @@ expressions: line: { when: Outage_line, expression: "at(Line_s_monitored, by=Outage_line, over=line, into=outage)" } otherwise: at(Transformer_s_monitored, by=Outage_transformer, over=transformer, into=outage) + Generator_injection: + expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + Line_injection: + expression: >- + -sum(Line_s, by=Line_bus0, over=line, into=bus) + + sum(Line_s, by=Line_bus1, over=line, into=bus) + - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) + - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) + Link_injection: + expression: >- + -sum(Link_p, by=Link_bus0, over=link, into=bus) + + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) + Process_injection: + expression: >- + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + StorageUnit_injection: + expression: >- + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + Transformer_injection: + expression: >- + -sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) + + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) + - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) + - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) + Bus_injection: + expression: >- + Generator_injection + + Line_injection + + Link_injection + + Load_injection + + Process_injection + + StorageUnit_injection + + Store_injection + + Transformer_injection + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side + Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) + Transformer_angle_sum: + expression: >- + sum(Transformer_s * Transformer_cycle_weight, over=transformer) + + sum(Transformer_phase_shift_weight, over=transformer) + + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) + Cycle_angle_sum: + expression: Line_angle_sum + Transformer_angle_sum + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift + constraints: Generator_fix_p_lower: description: "`Generator-fix-p-lower` — a fixed generator outputs at least its minimum" @@ -3354,11 +3489,7 @@ constraints: constant where the shift is fixed, or the shift decision times its cycle weight where the shift is a phase-shifting transformer's to choose dims: [scenario, snapshot, cycle] - expression: >- - sum(Line_s * Line_cycle_weight, over=line) - + sum(Transformer_s * Transformer_cycle_weight, over=transformer) - + sum(Transformer_phase_shift_weight, over=transformer) - + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) == 0 + expression: Cycle_angle_sum == 0 Generator_p_ramp_limit_up: description: >- `Generator-p-ramp_limit_up` — a generator raises output no faster than @@ -3685,22 +3816,7 @@ constraints: an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that carries load, and this file does not yet. dims: [scenario, snapshot, bus] - expression: >- - sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) - + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) - + sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) - - sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) - + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) - - sum(Line_s, by=Line_bus0, over=line, into=bus) - + sum(Line_s, by=Line_bus1, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus) - - 0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus) - - sum(Transformer_s, by=Transformer_bus0, over=transformer, into=bus) - + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - - 0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus) - - 0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus) - == -sum(Load_demand, by=Load_bus, over=load, into=bus) + expression: Bus_injection == 0 Carrier_growth_limit: description: >- `Carrier-growth_limit` — what a carrier adds across its extendable components in a period, diff --git a/examples/pypsa/generator.yaml b/examples/pypsa/generator.yaml index 797b53d7..f429ba13 100644 --- a/examples/pypsa/generator.yaml +++ b/examples/pypsa/generator.yaml @@ -182,7 +182,14 @@ expressions: cases: modular_build: { when: Generator_p_nom_extendable AND Generator_p_nom_mod > 0, expression: Generator_p_nom_mod } otherwise: Generator_p_nom - Generator_injection: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + Generator_primary_energy: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + Generator_operational_limit: + expression: >- + sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + Generator_tech_capacity_expansion: + expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) Generator_opex: expression: >- sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) @@ -190,13 +197,8 @@ expressions: Generator_additions: expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) - Generator_primary_energy: - expression: >- - sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) - Generator_operational_limit: - expression: >- - sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) - Generator_tech_capacity_expansion: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + Generator_injection: + expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) constraints: Generator_fix_p_lower: diff --git a/examples/pypsa/line.yaml b/examples/pypsa/line.yaml index 6b744bfd..ce136665 100644 --- a/examples/pypsa/line.yaml +++ b/examples/pypsa/line.yaml @@ -162,6 +162,15 @@ given: Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Line_angle_sum } expressions: + Line_transmission_volume_expansion: + expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) + Line_transmission_expansion_cost: + expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + Line_tech_capacity_expansion: + expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + Line_additions: + expression: >- + sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, @@ -178,12 +187,6 @@ expressions: - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) - Line_additions: - expression: >- - sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) - Line_tech_capacity_expansion: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) - Line_transmission_volume_expansion: sum(Line_s_nom_ext * Line_volume_weight, over=line) - Line_transmission_expansion_cost: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) constraints: Line_fix_s_lower: diff --git a/examples/pypsa/link.yaml b/examples/pypsa/link.yaml index 7c35a95c..83c30f2c 100644 --- a/examples/pypsa/link.yaml +++ b/examples/pypsa/link.yaml @@ -233,10 +233,12 @@ expressions: when: Link_output_cyclic_delay expression: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge='wrap', by=snapshot_period, within=period) otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output) * Link_efficiency, along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) - Link_injection: - expression: >- - -sum(Link_p, by=Link_bus0, over=link, into=bus) - + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + Link_transmission_volume_expansion: + expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) + Link_transmission_expansion_cost: + expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + Link_tech_capacity_expansion: + expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) Link_opex: expression: >- sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) @@ -244,9 +246,10 @@ expressions: Link_additions: expression: >- sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) - Link_tech_capacity_expansion: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) - Link_transmission_volume_expansion: sum(Link_p_nom_ext * Link_volume_weight, over=link) - Link_transmission_expansion_cost: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + Link_injection: + expression: >- + -sum(Link_p, by=Link_bus0, over=link, into=bus) + + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) constraints: Link_fix_p_lower: diff --git a/examples/pypsa/network.yaml b/examples/pypsa/network.yaml index 85d77620..87ea1317 100644 --- a/examples/pypsa/network.yaml +++ b/examples/pypsa/network.yaml @@ -26,13 +26,12 @@ constraints: `Bus-nodal_balance` — what is generated at a bus, storage dispatch and stores included, less what the links take away, plus what arrives over them after losses and any delay at every port they deliver to, each - process port drawing or delivering at its own rate and each passive - branch carrying its flow, meets the load there, less half of every - incident line's and transformer's loss — PyPSA dissipates a branch's - loss half at either end. Each generator, storage unit, store and load - term enters with its component's `sign` (`constraints.py:1428-1429`, - `:1538`), and an inactive load not at all. A bus nothing is attached to - has no row; PyPSA refuses one that carries load, and this file does not - yet. + process port drawing or delivering at its own rate and each passive branch + carrying its flow, meets the load there, less half of every incident + line's and transformer's loss — PyPSA dissipates a branch's loss half at + either end. Each generator, storage unit, store and load term enters + with its component's `sign` (`constraints.py:1428-1429`, `:1538`), and + an inactive load not at all. A bus nothing is attached to has no row; PyPSA refuses one that + carries load, and this file does not yet. dims: [scenario, snapshot, bus] expression: Bus_injection == 0 diff --git a/examples/pypsa/power_flow.yaml b/examples/pypsa/power_flow.yaml index 2eedc18a..b7edbe54 100644 --- a/examples/pypsa/power_flow.yaml +++ b/examples/pypsa/power_flow.yaml @@ -24,8 +24,8 @@ constraints: description: >- `Kirchhoff-Voltage-Law` — around every independent cycle the impedance-weighted flows sum to nothing, which is what makes the linear - power flow physical rather than transport. A transformer's flow weighs - its effective reactance, and its phase shift enters the cycle sum too: a + power flow physical rather than transport. A transformer's flow weighs its + effective reactance, and its phase shift enters the cycle sum too: a constant where the shift is fixed, or the shift decision times its cycle weight where the shift is a phase-shifting transformer's to choose dims: [scenario, snapshot, cycle] diff --git a/examples/pypsa/process.yaml b/examples/pypsa/process.yaml index 3a81196d..ae5d26b0 100644 --- a/examples/pypsa/process.yaml +++ b/examples/pypsa/process.yaml @@ -209,9 +209,8 @@ expressions: when: Process_output_cyclic_delay expression: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge='wrap', by=snapshot_period, within=period) otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output) * Process_rate, along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) - Process_injection: - expression: >- - sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + Process_tech_capacity_expansion: + expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) Process_opex: expression: >- sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) @@ -219,7 +218,9 @@ expressions: Process_additions: expression: >- sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) - Process_tech_capacity_expansion: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + Process_injection: + expression: >- + sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) constraints: Process_fix_p_lower: diff --git a/examples/pypsa/core.yaml b/examples/pypsa/settings.yaml similarity index 100% rename from examples/pypsa/core.yaml rename to examples/pypsa/settings.yaml diff --git a/examples/pypsa/storage_unit.yaml b/examples/pypsa/storage_unit.yaml index 7b0dbafc..c7fe45e7 100644 --- a/examples/pypsa/storage_unit.yaml +++ b/examples/pypsa/storage_unit.yaml @@ -270,9 +270,14 @@ expressions: when: NOT StorageUnit_state_of_charge_initial_per_period expression: GlobalConstraint_snapshot_closes otherwise: 0 - StorageUnit_injection: + StorageUnit_primary_energy: expression: >- - sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + StorageUnit_operational_limit: + expression: >- + -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + StorageUnit_tech_capacity_expansion: + expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) StorageUnit_opex: expression: >- sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) @@ -282,13 +287,9 @@ expressions: StorageUnit_additions: expression: >- sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) - StorageUnit_primary_energy: - expression: >- - -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) - StorageUnit_operational_limit: + StorageUnit_injection: expression: >- - -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) - StorageUnit_tech_capacity_expansion: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) constraints: StorageUnit_fix_p_dispatch_lower: diff --git a/examples/pypsa/store.yaml b/examples/pypsa/store.yaml index 76c30c98..9d0da30a 100644 --- a/examples/pypsa/store.yaml +++ b/examples/pypsa/store.yaml @@ -228,7 +228,14 @@ expressions: when: NOT Store_e_initial_per_period expression: GlobalConstraint_snapshot_closes otherwise: 0 - Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + Store_primary_energy: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) + Store_operational_limit: + expression: >- + -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) + Store_tech_capacity_expansion: + expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) Store_opex: expression: >- sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) @@ -237,13 +244,7 @@ expressions: Store_additions: expression: >- sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) - Store_primary_energy: - expression: >- - -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) - Store_operational_limit: - expression: >- - -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) - Store_tech_capacity_expansion: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) constraints: Store_fix_e_lower: diff --git a/mkdocs.yml b/mkdocs.yml index 1f79f098..d43aa7c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,6 +95,32 @@ nav: - Every construct, as math: reference/notation.md - PyPSA in one file: examples/pypsa.md - PyPSA, the relaxed commitment: examples/pypsa_linearized_uc.md + - PyPSA in 24 files: + - examples/pypsa/index.md + - Settings: examples/pypsa/settings.md + - The network: examples/pypsa/network.md + - Power flow: examples/pypsa/power_flow.md + - The cost: examples/pypsa/cost.md + - Carriers: examples/pypsa/carrier.md + - Global constraints: examples/pypsa/global_constraints.md + - Security: examples/pypsa/security.md + - Generators: examples/pypsa/generator.md + - Generators, the commitment: examples/pypsa/generator_commitment.md + - Generators, the ramping: examples/pypsa/generator_ramping.md + - Generators, the maintenance: examples/pypsa/generator_maintenance.md + - Links: examples/pypsa/link.md + - Links, the commitment: examples/pypsa/link_commitment.md + - Links, the ramping: examples/pypsa/link_ramping.md + - Links, the maintenance: examples/pypsa/link_maintenance.md + - Processes: examples/pypsa/process.md + - Processes, the commitment: examples/pypsa/process_commitment.md + - Processes, the ramping: examples/pypsa/process_ramping.md + - Processes, the maintenance: examples/pypsa/process_maintenance.md + - Storage units: examples/pypsa/storage_unit.md + - Stores: examples/pypsa/store.md + - Lines: examples/pypsa/line.md + - Transformers: examples/pypsa/transformer.md + - Loads: examples/pypsa/load.md theme: palette: diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 7f3b4f40..d5bf073d 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -394,8 +394,10 @@ def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple return self.format.superscript(base, self._expression(node.exponent, ctx)), _PRECEDENCE['**'] op: BinaryOperator = '*' if isinstance(node, Multiply) else '+' precedence = _PRECEDENCE[op] + operand = self._substituted(node.right) + if op == '+' and isinstance(operand, Add): + return self._binary(Add(Add(node.left, operand.left), operand.right), ctx) left = self._expression(node.left, ctx, need=precedence) - operand = node.right if op == '+': while (unsigned := _unsigned(operand)) is not None: operand, op = unsigned, '-' if op == '+' else '+' @@ -405,6 +407,19 @@ def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple names: dict[BinaryOperator, OperatorName] = {'*': 'cdot', '+': 'plus', '-': 'minus'} return self.format.joined([left, right], self._op(names[op])), precedence + def _substituted(self, node: Expression) -> Expression: + """*node* with a plain named expression replaced by its body where inlining prints the body anyway. + + Done before the sign folding of [`_binary`][], so a term that opens + with a minus prints as a subtraction once substituted, as it does + written out: ``a + t`` with ``t: -b`` is ``a - b``, not ``a + -b``. A + body that is itself a sum is re-associated to the left for the same + reason: ``a + (-b + c)`` prints as ``a - b + c``. + """ + while self.inline_expressions and isinstance(node, Named) and not isinstance(node.body, Cases): + node = node.body + return node + def _sum(self, node: Sum, ctx: _Context) -> tuple[str, int]: """A reduction over named dims: one dummy index per dim, in declaration order.""" memberships = [] diff --git a/tests/test_pypsa_split.py b/tests/test_pypsa_split.py index 486ccbe6..af810ea3 100644 --- a/tests/test_pypsa_split.py +++ b/tests/test_pypsa_split.py @@ -20,7 +20,7 @@ from mathspec import FORMATS, LanguageError, merge, to_spec, typeset from mathspec.canonical import canonical_yaml -from tools.pypsa_split import SUM_HOME, Model, fragments, same_rows +from tools.pypsa_split import SOURCE, SUM_HOME, Model, fragments FOLDER = Path(__file__).resolve().parent.parent / 'examples' / 'pypsa' PATHS = {path.stem: path for path in sorted(FOLDER.glob('*.yaml'))} @@ -36,9 +36,9 @@ def test_a_fragment_loads_on_its_own(name): assert to_spec(PATHS[name]).program -def test_the_fragments_merge_to_the_one_file_with_its_hubs_as_sums(model): +def test_the_fragments_merge_to_the_one_file(model): merged = merge(PATHS, description=model.data['description']) - assert canonical_yaml(merged) == canonical_yaml(to_spec(model.data)) + assert canonical_yaml(merged) == canonical_yaml(to_spec(SOURCE)) assert not merged.program.given, 'every name a fragment reads, another fragment declares' @@ -51,10 +51,6 @@ def test_each_sum_is_its_terms_by_name_and_each_term_stays(model): assert set(model.terms) <= set(merged.expressions), 'every term is a named expression of the composed spec' -def test_the_one_file_with_its_hubs_as_sums_states_the_rows_of_pypsa_yaml(model): - assert same_rows(model.original, model.data) == [], 'each hub substituted back gives the rows the file wrote' - - def test_the_fragments_are_what_the_splitter_writes(model): written = fragments(model) assert sorted(written) == sorted(PATHS), 'one file per topic, and no stale one' @@ -103,8 +99,8 @@ def test_every_sum_is_read_with_its_description_in_one_fragment(model): ) for name in model.sums } - assert described == {name: [SUM_HOME.get(name, 'core')] for name in model.sums}, ( - 'the reader of a sum no model goes without describes it, and core describes the rest' + assert described == {name: [SUM_HOME.get(name, 'settings')] for name in model.sums}, ( + 'the reader of a sum no model goes without describes it, and settings describes the rest' ) diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index 321fd3fa..e85c74ed 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -124,6 +124,26 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ ) +#: A row that adds a named term opening with a minus: substituted, the minus folds into the operator before it. +NEGATED_TERM = varied( + PLAIN, + **{ + 'expressions.drawn': '-spend + total', + 'constraints.net': {'dims': ['snapshot'], 'expression': 'spend + drawn == 0'}, + }, +) + + +def test_a_substituted_term_that_opens_with_a_minus_prints_as_a_subtraction(): + """`spend + drawn` with `drawn: -spend + total` printed `spend + -spend + total` once substituted, where the + file written out prints `spend - spend + total`.""" + assert typeset_declaration(NEGATED_TERM, 'net', 'latex') == ( + r'\sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} ' + r'- \left( \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \right) ' + r'+ \sum_{g \in \mathcal{G}} p_{t,g} = 0 \qquad \forall\, t \in \mathcal{T}' + ) + + #: A column and a row family this file reads, each named by something that prints. GIVEN = varied( PLAIN, diff --git a/tools/gallery.py b/tools/gallery.py index bbb6c3a2..bfa779e1 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -21,7 +21,8 @@ import yaml -from mathspec import merge, override, to_spec +from mathspec import merge, override, to_spec, typeset_declaration +from mathspec.program import Add, Constant, Named from mathspec.typesetting import to_markdown from tools._page import ROOT, sidecar_for, splice, tab, without_header from tools._page import main as page_main @@ -35,10 +36,13 @@ PAGES = ROOT / 'docs' / 'examples' LIBRARY = ROOT / 'examples' / 'library' +PYPSA = ROOT / 'examples' / 'pypsa' #: How `examples/library/` prints: one table for every fragment, the spec #: they compose and each variant laid over it. `symbols_for` cuts it to what -#: one spec declares, because a table naming anything else is refused. +#: one spec declares, because a table naming anything else is refused. The +#: PyPSA split prints in the one file's table, cut the same way. LIBRARY_SYMBOLS = ROOT / 'examples' / 'symbols' / 'library.yaml' +PYPSA_SYMBOLS = ROOT / 'examples' / 'symbols' / 'pypsa.yaml' BEGIN, END = '', '' #: Page -> the spec it shows. One spec per page, because a gallery of @@ -49,8 +53,12 @@ 'library/surface.md': LIBRARY / 'surface.yaml', 'library/generator.md': LIBRARY / 'generator.yaml', 'library/load.md': LIBRARY / 'load.yaml', + **{f'pypsa/{path.stem}.md': path for path in sorted(PYPSA.glob('*.yaml'))}, } +#: The index of the PyPSA split: its two tables are read off the fragments. +SPLIT_INDEX = 'pypsa/index.md' + #: Page -> the fragments whose composition it shows, and the patches laid over #: it. The spec is what `merge` returns, which no file in the tree holds, so #: the page carries it as YAML beside the math it prints. A patch has no math @@ -82,10 +90,20 @@ def model_block(path: Path) -> str: return f'```yaml\n{without_header(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' -def symbols_for(model: Spec) -> dict[str, Any]: - """The library's symbol table, cut to the dimensions and names *spec* declares.""" - table = yaml.safe_load(LIBRARY_SYMBOLS.read_text()) - named = {*model.parameters, *model.variables, *model.given.variables, *model.expressions, *model.constraints} +def symbols_for(model: Spec, table_path: Path = LIBRARY_SYMBOLS) -> dict[str, Any]: + """The symbol table at *table_path*, cut to the dimensions and names *model* declares or reads.""" + table = yaml.safe_load(table_path.read_text()) + given = model.given + named = { + *model.parameters, + *model.variables, + *model.expressions, + *model.constraints, + *given.parameters, + *given.variables, + *given.expressions, + *given.constraints, + } return { 'notation': table['notation'], 'dimensions': {name: symbol for name, symbol in table['dimensions'].items() if name in model.dimensions}, @@ -93,13 +111,48 @@ def symbols_for(model: Spec) -> dict[str, Any]: } -def library_block(path: Path) -> str: - """One fragment of the library, then its document in the notation the whole library prints in.""" +def fragment_block(path: Path, table_path: Path) -> str: + """One fragment, then its document in the notation the whole composition prints in.""" model = to_spec(path) - printed = to_markdown(model, symbols=symbols_for(model), numbered=False) + printed = to_markdown(model, symbols=symbols_for(model, table_path), numbered=False) return f'```yaml\n{without_header(path)}\n```\n\n{printed.strip()}' +def split_index_block() -> str: + """The PyPSA split's two tables: each sum with the fragment that reads it and the terms, and each fragment. + + Both are read off the fragments, so the index cannot name a term or a + reader the files no longer have. + """ + specs = {path.stem: to_spec(path) for path in sorted(PYPSA.glob('*.yaml'))} + readers: dict[str, tuple[str, tuple[str, ...]]] = {} + terms: dict[str, dict[str, str]] = {} + for name, spec in specs.items(): + for hub, entry in spec.given.expressions.items(): + if entry.term is not None: + terms.setdefault(hub, {})[name] = entry.term + elif entry.description: + readers[hub] = (name, tuple(entry.dims)) + sums = ['| Sum | Over | Read in | The terms, by the fragment that adds each |', '| --- | --- | --- | --- |'] + for hub, by_fragment in sorted(terms.items(), key=lambda item: -len(item[1])): + reader, dims = readers[hub] + cells = ', '.join(f'[`{term}`]({fragment}.md)' for fragment, term in sorted(by_fragment.items())) + sums.append(f'| `{hub}` | `{", ".join(dims)}` | [{reader}]({reader}.md) | {cells} |') + files = [ + '| Fragment | Parameters | Variables | Constraints | Reads | Adds to |', + '| --- | --- | --- | --- | --- | --- |', + ] + for name, spec in specs.items(): + given = spec.given + reads = len(given.parameters) + len(given.variables) + len(given.expressions) + len(given.constraints) + adds = ', '.join(f'`{hub}`' for hub, entry in given.expressions.items() if entry.term is not None) + files.append( + f'| [{name}]({name}.md) | {len(spec.parameters)} | {len(spec.variables)} | {len(spec.constraints)} ' + f'| {reads} | {adds} |' + ) + return '### The sums\n\n' + '\n'.join(sums) + '\n\n### The fragments\n\n' + '\n'.join(files) + + def composed_block(fragments: list[Path], patches: dict[str, Path]) -> str: """The spec `merge` returns for *fragments* as YAML, then its document as composed and under each patch. @@ -187,11 +240,15 @@ def declared_block(path: Path) -> str: assumption = equations(_section(page, 'Assumptions')) if model.assumptions else {} parts = [legend, f'### Objective\n\n```yaml\n{declaration(text, "objective")}\n```\n\n{objective}'] for name, block in model.constraints.items(): + printed = equation[name] + if _reads_a_sum(model, name): + line = typeset_declaration(model, name, 'markdown', symbols=sidecar_for(path), inline_expressions=True) + printed = f'```math\n{line}\n```' parts.append( f'### `{_stands_for(name, block.description)}`\n\n' f'`{name}`\n\n' f'```yaml\n{declaration(text, "constraints", name)}\n```\n\n' - f'{equation[name]}' + f'{printed}' ) parts.extend( f'### `{name}`\n\n```yaml\n{declaration(text, "expressions", name)}\n```\n\n{definition[name]}' @@ -205,6 +262,29 @@ def declared_block(path: Path) -> str: return '\n\n'.join(parts) +def _summands(node: object) -> list[object]: + """The terms of *node* read as a flat sum.""" + return [*_summands(node.left), *_summands(node.right)] if isinstance(node, Add) else [node] + + +def _reads_a_sum(model: Spec, name: str) -> bool: + """Whether the row *name* is ``hub == 0`` over a named expression that is a sum of named expressions. + + Such a row prints with the sum substituted in, as the one file wrote it + before each term had a name: the row is what the reader came for, and the + terms print once each as definitions below. + """ + row = model.program.constraints[name] + lhs, rhs = row.lhs, row.rhs + return ( + isinstance(lhs, Named) + and isinstance(rhs, Constant) + and rhs.value == 0 + and all(isinstance(term, Named) for term in _summands(lhs.body)) + and len(_summands(lhs.body)) > 1 + ) + + def _script(name: str) -> str: """A rung's PyPSA script, verbatim — the model under review is the code itself.""" return f'`{name}.py`\n\n```python\n{(REFERENCES / f"{name}.py").read_text().strip()}\n```' @@ -275,8 +355,12 @@ def block(page: str) -> str: return declared_block(DECLARED[page]) if page in COMPOSED: return composed_block(*COMPOSED[page]) + if page == SPLIT_INDEX: + return split_index_block() if MODELS[page].parent == LIBRARY: - return library_block(MODELS[page]) + return fragment_block(MODELS[page], LIBRARY_SYMBOLS) + if MODELS[page].parent == PYPSA: + return fragment_block(MODELS[page], PYPSA_SYMBOLS) return model_block(MODELS[page]) @@ -288,7 +372,7 @@ def rendered(page: str, text: str) -> str: def pages() -> list[str]: - return [*MODELS, *COMPOSED, *DECLARED, 'operators.md'] + return [*MODELS, *COMPOSED, *DECLARED, 'operators.md', SPLIT_INDEX] def main(argv: list[str] | None = None) -> int: diff --git a/tools/pypsa_split.py b/tools/pypsa_split.py index 2e079165..7dc4ef2f 100644 --- a/tools/pypsa_split.py +++ b/tools/pypsa_split.py @@ -2,31 +2,29 @@ # # SPDX-License-Identifier: MIT -"""Cut `examples/pypsa.yaml` into topic fragments, and check that `merge` gives the same model back (experiment, #722). +"""Cut `examples/pypsa.yaml` into topic fragments, and check that `merge` gives the same spec back (experiment, #722). Run from the repository root:: python -m tools.pypsa_split split OUT # write the topic fragments into OUT python -m tools.pypsa_split check OUT # load each fragment, merge them, compare -A fragment reads what another topic declares under `given:`. A row or a named -expression that sums one term per component (a hub) becomes a sum: each -component declares its share as a named expression of its own, such as -`Generator_injection`, and names it as the `term:` of its `given:` entry for -the hub. One fragment reads each hub with its description, so a term always -lands, and a new component is one new fragment. - -`check` compares twice. The merged fragments against the one file with its -hubs written as sums, in the canonical form. And that file against -`examples/pypsa.yaml`, row by row: every hub substituted back where it is read, -and every term of a row moved to one side. +The one file writes each hub, a row or a named expression that every component +adds its share to, as the sum of named terms: `Bus_injection` is +`Generator_injection + … + Transformer_injection`, and `Bus_nodal_balance` +reads `Bus_injection == 0`. A fragment owns the declarations of its topic, its +terms among them, and reads what another topic declares under `given:`; the +entry for a hub names the fragment's term. One fragment reads each hub with its +description, so a term always lands, and a new component is one new fragment. + +`merge` then writes each hub as the file does, so `check` is one comparison: +the merged fragments and the one file have one canonical form. """ from __future__ import annotations import argparse import collections -import copy import difflib import itertools import re @@ -38,17 +36,8 @@ import yaml from mathspec import merge, to_spec -from mathspec._expression_parser import ( - BinaryOperatorNode, - ComparisonNode, - NameNode, - NumberNode, - UnaryOperatorNode, - operand, - parse_expression, - with_children, -) -from mathspec.canonical import canonical_dict, canonical_yaml, normalised +from mathspec._expression_parser import BinaryOperatorNode, NumberNode, UnaryOperatorNode, operand, parse_expression +from mathspec.canonical import canonical_yaml from mathspec.errors import LanguageError if TYPE_CHECKING: @@ -85,10 +74,10 @@ #: topic reads: the risk preference, and how a snapshot counts in a global #: constraint, which a component's share of one reads. NAME_TOPIC = { - 'CVaR_omega': 'core', - 'GlobalConstraint_counts_snapshot': 'core', - 'GlobalConstraint_energy_weight': 'core', - 'GlobalConstraint_snapshot_closes': 'core', + 'CVaR_omega': 'settings', + 'GlobalConstraint_counts_snapshot': 'settings', + 'GlobalConstraint_energy_weight': 'settings', + 'GlobalConstraint_snapshot_closes': 'settings', 'scenario_opex': 'cost', 'primary_energy': 'global_constraints', 'operational_limit': 'global_constraints', @@ -97,23 +86,11 @@ 'tech_capacity_expansion': 'global_constraints', } -#: The rows that sum a term per component: each becomes `name == 0` over an -#: additive expression, which every component adds its terms to. -HUB_ROWS = { - 'Bus_nodal_balance': ( - 'Bus_injection', - 'what every component puts into a bus, less what it takes out of it; PyPSA writes each term into ' - 'the balance, and a load on its right-hand side', - ), - 'Kirchhoff_Voltage_Law': ( - 'Cycle_angle_sum', - 'the voltage angle differences around a cycle: every branch flow times its cycle weight, and every ' - 'transformer phase shift', - ), -} - -#: The named expressions that sum a term per component, each made a sum. -HUB_EXPRESSIONS = ( +#: The hubs: the named expressions the one file writes as the sum of one term +#: per component. Two are what a balance row reads, the rest are totals. +HUBS = ( + 'Bus_injection', + 'Cycle_angle_sum', 'scenario_opex', 'Carrier_additions', 'primary_energy', @@ -123,20 +100,11 @@ 'transmission_expansion_cost', ) -#: The fragment that reads a sum with its description: the reader where a +#: The fragment that reads a hub with its description: the reader where a #: model without it is never wanted, so the terms land nowhere without it, and -#: `core` where the reader may go. +#: `settings` where the reader may go. SUM_HOME = {'Bus_injection': 'network', 'Cycle_angle_sum': 'power_flow'} -#: How a term of each sum is named after its hub: `Generator_injection` is -#: what the generators put into `Bus_injection`. -HUB_SHORT = { - 'Bus_injection': 'injection', - 'Cycle_angle_sum': 'angle_sum', - 'scenario_opex': 'opex', - 'Carrier_additions': 'additions', -} - #: The component each topic that adds a term is named after. TOPIC_PREFIX = {topic: prefix for prefix, topic in PREFIX_TOPIC.items() if prefix[0].isupper()} | { 'power_flow': 'Cycle' @@ -158,19 +126,12 @@ ) -def term_name(hub: str, name: str) -> str: - """What the topic *name* calls its term of *hub*: `Generator_commitment_opex` for the start-up costs.""" - base = next(b for b in sorted(TOPIC_PREFIX, key=len, reverse=True) if name == b or name.startswith(f'{b}_')) - feature = name.removeprefix(base).removeprefix('_') - return '_'.join(filter(None, (TOPIC_PREFIX[base], feature, HUB_SHORT.get(hub, hub)))) - - def topic(name: str) -> str: """The fragment a declaration of *name* goes to.""" if 'security' in name or 'BODF' in name: return 'security' prefix, _, rest = name.partition('_') - base = NAME_TOPIC.get(name) or PREFIX_TOPIC.get(prefix, 'core') + base = NAME_TOPIC.get(name) or PREFIX_TOPIC.get(prefix, 'settings') if prefix in COMMITTABLE: return next((f'{base}_{feature}' for feature, pattern in FEATURES if pattern.search(rest)), base) return base @@ -227,70 +188,37 @@ def _signed_terms(node: ArithmeticNode, sign: str = '+') -> Iterator[Term]: yield sign, node -def _written(terms: list[Term]) -> str: - """Signed terms as one expression, one term per line of a folded block.""" - (sign, head), *rest = terms - first = str(head) if sign == '+' else f'-{operand(head)}' - return '\n'.join([first, *(f'{s} {operand(node)}' for s, node in rest)]) - - def _entry(block: object) -> dict[str, Any]: """A named expression as a mapping, however the file wrote it.""" return dict(block) if isinstance(block, dict) else {'expression': block} class Model: - """`examples/pypsa.yaml` with every hub written as the sum of its named terms, one per topic. - - `data` is what `merge` gives back from the fragments: each hub defined as - its terms by name in the order the topics sort in, with the hub's - description, and each term a named expression. - """ + """`examples/pypsa.yaml`, with each hub's terms and the topic each term belongs to.""" def __init__(self, path: Path = SOURCE) -> None: text = path.read_text() - self.original: dict[str, Any] = yaml.safe_load(text) - self.data: dict[str, Any] = copy.deepcopy(self.original) + self.data: dict[str, Any] = yaml.safe_load(text) self.blocks = _sliced(text) - self.generated: set[Key] = set() self.kind = {n: s for s in SECTIONS if s not in ('constraints', 'assumptions') for n in self.data.get(s, {})} - bodies: dict[str, list[Term]] = {} - said = dict(HUB_ROWS.values()) - for row, (name, _) in HUB_ROWS.items(): - block = self.data['constraints'][row] - compared = parse_expression(block['expression']) - assert isinstance(compared, ComparisonNode), f'{row} is a comparison' - assert compared.op == '==', f'{row} is a balance' - bodies[name] = [*_signed_terms(compared.left), *_signed_terms(compared.right, '-')] - self.data['constraints'][row] = {**block, 'expression': f'{name} == 0'} - self.generated.add(('constraints', row)) - for name in HUB_EXPRESSIONS: - entry = _entry(self.data['expressions'][name]) - said[name] = entry.get('description') - bodies[name] = list(_signed_terms(parse_expression(entry['expression']))) - self.shares: dict[str, dict[str, list[Term]]] = {} - self.terms: dict[str, str] = {} - for name, terms in bodies.items(): - by_topic: dict[str, list[Term]] = collections.defaultdict(list) - for sign, node in terms: - by_topic[self._owner(node)].append((sign, node)) - self.shares[name] = dict(sorted(by_topic.items())) - for owner, share in self.shares[name].items(): - term = term_name(name, owner) - assert term not in self.kind and term not in self.terms, f'{term} names one thing' - self.terms[term] = name - self.data['expressions'][term] = {'expression': _written(share)} - self.generated.add(('expressions', term)) - hub = {'expression': ' + '.join(term_name(name, owner) for owner in self.shares[name])} - self.data['expressions'][name] = {**hub, 'description': said[name]} if said[name] else hub - self.generated.add(('expressions', name)) - self.kind |= dict.fromkeys((*self.terms, *bodies), 'expressions') + #: hub -> the topic of each term -> the term, in the order the topics sort in. + self.shares: dict[str, dict[str, str]] = {} + for hub in HUBS: + terms = IDENT.findall(_entry(self.data['expressions'][hub])['expression']) + by_topic = {self._owner(_entry(self.data['expressions'][term])['expression']): term for term in terms} + assert len(by_topic) == len(terms), f'{hub}: one term per topic' + self.shares[hub] = dict(sorted(by_topic.items())) + #: term -> the hub it adds to. + self.terms = {term: hub for hub, by_topic in self.shares.items() for term in by_topic.values()} frames = self.frames() - self.sums = {name: {'dims': list(frames[name]), 'description': said[name]} for name in bodies} + self.sums = { + hub: {'dims': list(frames[hub]), 'description': _entry(self.data['expressions'][hub]).get('description')} + for hub in HUBS + } - def _owner(self, node: ArithmeticNode) -> str: - """The topic of a hub's term: the component whose variable it reads, else whose expression or data.""" - read = [n for n in IDENT.findall(str(node)) if n in self.kind and self.kind[n] not in FRAME] + def _owner(self, text: str) -> str: + """The topic of a term: the component whose variable it reads, else whose expression or data.""" + read = [n for n in IDENT.findall(text) if n in self.kind and self.kind[n] not in FRAME] return topic(min(read, key=lambda n: ('variables', 'expressions', 'parameters').index(self.kind[n]))) def names_in(self, value: object) -> set[str]: @@ -301,16 +229,16 @@ def key(self, name: str) -> Key: @property def keys(self) -> list[Key]: - """Every declaration, in the order of the source file, the new hubs after the source's own.""" - return [*self.blocks, *sorted(k for k in self.generated if k not in self.blocks)] + """Every declaration, in the order of the source file.""" + return list(self.blocks) def frames(self) -> dict[str, tuple[str, ...]]: - """The frame of every named expression, read off the one file with its hubs rewritten.""" + """The frame of every named expression, read off the one file.""" return {name: e.dims for name, e in to_spec(self.data).program.expressions.items()} def home(self, name: str) -> str: - """The fragment that reads the sum *name* with its description.""" - return SUM_HOME.get(name, 'core') + """The fragment that reads the hub *name* with its description.""" + return SUM_HOME.get(name, 'settings') # --------------------------------------------------------------------------- @@ -319,30 +247,27 @@ def home(self, name: str) -> str: def fragments(model: Model) -> dict[str, str]: - """Each topic's fragment as YAML text: what it owns, its shares of the hubs, and what it reads under `given:`.""" + """Each topic's fragment as YAML text: what it owns, its terms among it, and what it reads under `given:`.""" frames = model.frames() owned: dict[str, set[Key]] = collections.defaultdict(set) for key in model.keys: - if key[0] not in FRAME and not (key[0] == 'expressions' and (key[1] in model.shares or key[1] in model.terms)): + if key[0] not in FRAME and not (key[0] == 'expressions' and (key[1] in HUBS or key[1] in model.terms)): owned[topic(key[1])].add(key) - sharing = {t for by_topic in model.shares.values() for t in by_topic} + for by_topic in model.shares.values(): + for name, term in by_topic.items(): + owned[name].add(('expressions', term)) terms = _objective_terms(model) - homes = {model.home(hub) for hub in model.sums} + homes = {model.home(hub) for hub in HUBS} written = {} - for name in sorted({*owned, *sharing, *terms, *homes}): + for name in sorted({*owned, *terms, *homes}): mine = owned[name] - my_shares = {hub: by_topic[name] for hub, by_topic in model.shares.items() if name in by_topic} - homes_here = {hub for hub in model.sums if model.home(hub) == name} + adds = {hub: by_topic[name] for hub, by_topic in model.shares.items() if name in by_topic} + homes_here = {hub for hub in HUBS if model.home(hub) == name} read = set().union( - *(model.names_in(model.data[s][n]) for s, n in mine), - *(model.names_in(_written(t)) for t in my_shares.values()), - *map(model.names_in, terms[name]), - homes_here, - my_shares, + *(model.names_in(model.data[s][n]) for s, n in mine), *map(model.names_in, terms[name]), homes_here, adds ) - defined = mine | {('expressions', term_name(hub, name)) for hub in my_shares} - given = {model.key(n) for n in read if model.key(n)[0] not in FRAME and model.key(n) not in defined} + given = {model.key(n) for n in read if model.key(n)[0] not in FRAME and model.key(n) not in mine} stated = { **{n: model.data[s][n]['dims'] for s, n in given if s in ('parameters', 'variables')}, **{n: list(frames[n]) for s, n in given if s == 'expressions'}, @@ -350,8 +275,7 @@ def fragments(model: Model) -> dict[str, str]: frame_reads = read | set().union(*(model.names_in(dims) for dims in stated.values())) frame = {model.key(n) for n in frame_reads if model.key(n)[0] in FRAME} frame |= {model.key(n) for key in list(frame) for n in model.names_in(model.data[key[0]][key[1]])} - adds = {hub: term_name(hub, name) for hub in my_shares} - written[name] = _fragment(model, mine | frame, my_shares, given, stated, terms[name], adds, homes_here) + written[name] = _fragment(model, mine | frame, given, stated, terms[name], adds, homes_here) return written @@ -368,14 +292,6 @@ def _objective_terms(model: Model) -> dict[str, list[str]]: return terms -def _block(model: Model, key: Key) -> str: - """One declaration as the source wrote it, or as the hub rewrite wrote it.""" - if key not in model.generated: - return model.blocks[key] - section, name = key - return _dumped(name, _entry(model.data[section][name])) - - def _dumped(name: str, block: Mapping[str, object]) -> str: """A generated declaration, its long text folded one term per line.""" lines = [f' {name}:'] @@ -398,7 +314,6 @@ def _dumped(name: str, block: Mapping[str, object]) -> str: def _fragment( model: Model, included: set[Key], - shares: Mapping[str, list[Term]], given: set[Key], stated: Mapping[str, list[str]], terms: list[str], @@ -408,9 +323,7 @@ def _fragment( """One fragment as YAML text, its sections and declarations in the order of the source file.""" parts = [HEADER] for section in SECTIONS: - blocks = [_block(model, key) for key in model.keys if key[0] == section and key in included] - if section == 'expressions': - blocks += [_share(adds[hub], share) for hub, share in shares.items()] + blocks = [model.blocks[key] for key in model.keys if key[0] == section and key in included] if blocks: parts.append(f'{section}:\n' + '\n'.join(blocks) + '\n') if section == 'variables' and given: @@ -423,14 +336,6 @@ def _fragment( return '\n'.join(parts) -def _share(term: str, terms: list[Term]) -> str: - """One topic's term of a hub: an ordinary named expression under the term's own name.""" - written = _written(terms) - if '\n' in written or len(written) > 80: - return _dumped(term, {'expression': written}) - return f' {term}: {written}' - - #: The kinds a fragment reads under `given:`, and the fields of the source #: declaration each restates beside the frame. GIVEN_KINDS = {'parameters': ('dtype',), 'variables': ('domain',), 'expressions': ()} @@ -439,7 +344,7 @@ def _share(term: str, terms: list[Term]) -> str: def _given( model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], adds: Mapping[str, str], homes: set[str] ) -> str: - """One kind of a fragment's `given:` block, an entry per line in source order: a term it adds, a sum's prose.""" + """One kind of a fragment's `given:` block, an entry per line in source order: a term it adds, a hub's prose.""" names = [n for s, n in model.keys if s == kind and (s, n) in given] if not names: return '' @@ -463,79 +368,12 @@ def _given( # --------------------------------------------------------------------------- -# the checks +# the check # --------------------------------------------------------------------------- -def _inlined(node: ArithmeticNode, bodies: Mapping[str, ArithmeticNode]) -> ArithmeticNode: - """*node* with every name in *bodies* replaced by its body, and the names in that body in turn.""" - if isinstance(node, NameNode) and node.name in bodies: - return _inlined(bodies[node.name], bodies) - return with_children(node, lambda child: _inlined(child, bodies)) - - -def _row(text: str, bodies: Mapping[str, ArithmeticNode]) -> tuple[str, collections.Counter[tuple[str, str]]]: - """A row or an expression as its operator and the multiset of its terms, every term on the left.""" - node = parse_expression(text) - if isinstance(node, ComparisonNode): - op = node.op - terms = [*_signed_terms(_inlined(node.left, bodies)), *_signed_terms(_inlined(node.right, bodies), '-')] - else: - op, terms = '', list(_signed_terms(_inlined(node, bodies))) - return op, collections.Counter((sign, str(normalised(term))) for sign, term in terms) - - -def same_rows(original: Mapping[str, Any], rewritten: Mapping[str, Any]) -> list[str]: - """Every way *rewritten* states other rows than *original*, once the hubs *original* lacks are substituted. - - The declarations other than rows and named expressions are compared in the - canonical form. A named expression is compared term by term, and one - *original* does not declare has to be a hub or a term of one. - """ - before, after = (canonical_dict(to_spec(dict(model))) for model in (original, rewritten)) - faults = [ - f'{section} differ' - for section in ('dimensions', 'relations', 'parameters', 'variables', 'assumptions', 'macros') - if before.get(section) != after.get(section) - ] - added = set(after['expressions']) - set(before['expressions']) - faults += [f'{name} is new and is neither a hub nor a term' for name in added - {*HUB_NAMES, *_term_names()}] - bodies = {name: parse_expression(_entry(after['expressions'][name])['expression']) for name in added} - - def compared(label: str, one: Mapping[str, Any], two: Mapping[str, Any]) -> None: - beside = [{k: v for k, v in side.items() if k != 'expression'} for side in (one, two)] - if beside[0] != beside[1]: - faults.append(f'{label} differs beside its expression') - if _row(one['expression'], {}) != _row(two['expression'], bodies): - faults.append(f'{label} states another row') - - if set(before['constraints']) != set(after['constraints']): - faults.append('the constraints are not the same names') - for name in before['constraints'].keys() & after['constraints'].keys(): - compared(f'constraint {name}', before['constraints'][name], after['constraints'][name]) - for name, entry in before['expressions'].items(): - one, two = _entry(entry), _entry(after['expressions'][name]) - if 'expression' in one: - compared(f'expression {name}', one, two) - elif one != two: - faults.append(f'expression {name} differs') - compared('the objective', before['objective'], after['objective']) - return faults - - -#: Every hub, the rows' and the named expressions'. -HUB_NAMES = (*(name for name, _ in HUB_ROWS.values()), *HUB_EXPRESSIONS) - - -def _term_names() -> set[str]: - """Every name a term may take: each component, with each feature, for each hub.""" - topics = {*TOPIC_PREFIX, *(f'{t}_{f}' for t in (p.lower() for p in COMMITTABLE) for f, _ in FEATURES)} - return {term_name(hub, t) for hub in HUB_NAMES for t in topics} - - def check(folder: Path) -> int: - """Load each fragment in *folder*, merge them, and compare with the rewritten one file and with `examples/pypsa.yaml`.""" - model = Model() + """Load each fragment in *folder*, merge them, and compare the canonical form with `examples/pypsa.yaml`.""" paths = {path.stem: path for path in sorted(folder.glob('*.yaml'))} failed = 0 for name, path in paths.items(): @@ -545,18 +383,19 @@ def check(folder: Path) -> int: failed += 1 print(f'{name} does not load alone: {e}') print(f'{len(paths) - failed}/{len(paths)} fragments load alone') - rewritten = to_spec(model.data) + one = to_spec(SOURCE) try: - merged = merge(paths, description=rewritten.description) + merged = merge(paths, description=one.description) except LanguageError as e: print(f'merge refuses the fragments: {e}') return 1 - one, composed = canonical_yaml(rewritten).splitlines(), canonical_yaml(merged).splitlines() - diff = list(difflib.unified_diff(one, composed, 'one file', 'merged', lineterm='')) + diff = list( + difflib.unified_diff( + canonical_yaml(one).splitlines(), canonical_yaml(merged).splitlines(), 'one file', 'merged', lineterm='' + ) + ) print('\n'.join(diff) if diff else 'the merged fragments and the one file have one canonical form') - faults = same_rows(model.original, model.data) - print('\n'.join(faults) if faults else f'the one file with its hubs as sums states the rows of {SOURCE}') - return 1 if diff or failed or faults else 0 + return 1 if diff or failed else 0 def main(argv: list[str] | None = None) -> int: From 01ddae8eae151c88ea23c5fd7df73ae0869fe1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:04:40 +0000 Subject: [PATCH 65/75] Expect the substituted sum over both dimensions, and link the changelog line to #740 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- tests/typesetting/test_declaration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729af0b8..ad8b6e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp - refactor(language): a term prints as the definition it is, and one rule folds every reading ([#738](https://github.com/energy-models/mathspec/pull/738)) - feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731)) - fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out ([#739](https://github.com/energy-models/mathspec/pull/739)) -- fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment +- fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment ([#740](https://github.com/energy-models/mathspec/pull/740)) ## 0.2.0 (2026-09-25) diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index e85c74ed..0e97930f 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -140,7 +140,7 @@ def test_a_substituted_term_that_opens_with_a_minus_prints_as_a_subtraction(): assert typeset_declaration(NEGATED_TERM, 'net', 'latex') == ( r'\sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} ' r'- \left( \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \right) ' - r'+ \sum_{g \in \mathcal{G}} p_{t,g} = 0 \qquad \forall\, t \in \mathcal{T}' + r"+ \sum_{t' \in \mathcal{T},\ g \in \mathcal{G}} p_{t',g} = 0 \qquad \forall\, t \in \mathcal{T}" ) From ddba9f3f8bf2726884be171222bcc392fae15f5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:34:05 +0000 Subject: [PATCH 66/75] feat(language): a named expression may declare the frame it is read over Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 1 + docs/reference/language/named.md | 26 +++++++++++++++------ schema/mathspec.schema.json | 2 +- src/mathspec/dimensions.py | 20 +++++++++++++--- src/mathspec/lowering.py | 8 +++---- src/mathspec/spec.py | 26 +++++++++++---------- tests/test_dimensions.py | 40 +++++++++++++++++++++++++++++++- tests/test_validation.py | 5 ---- 8 files changed, 95 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8026a946..749a0ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): a named expression may declare the frame it is read over - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index 3df1763d..de20662c 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -28,8 +28,20 @@ expressions: description: CO2 released, the quantity a cap would bound ``` -It is a bare string, or a mapping with a `description:`. Its body decides its -dimensions, and there is no `dims:`. +It is a bare string, or a mapping with a `description:` and a `dims:`. The +`dims:` are the **frame**, the dimensions the quantity is read over. Left out, +the body decides the frame. Declared, the body may carry no dimension the +frame does not name, which the loader checks, and it may carry fewer: the +quantity is then constant along the rest, and a constraint over the whole frame +reads it at every coordinate. + +```yaml +expressions: + cap: + dims: [snapshot, generator] + expression: p_max + description: the nominal capacity, the same in every snapshot +``` Where the objective or a constraint names it, the body is substituted there, and the [degree limit](expressions.md#where-a-product-of-two-variables-is-allowed) @@ -70,11 +82,11 @@ $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{co A named expression carries **exactly one** of `expression:` and `cases:`. -| Key | | -| ----------- | ------------------------------------------------------------------------------------------------- | -| `dims` | required with `cases:`, and refused without. The **frame**: the dimensions every case ranges over | -| `cases` | a map of named cases, each with a `when:` mask and an `expression:` | -| `otherwise` | required. The value at every coordinate the cases leave | +| Key | | +| ----------- | ---------------------------------------------------------------------------- | +| `dims` | required with `cases:`. The **frame**: the dimensions every case ranges over | +| `cases` | a map of named cases, each with a `when:` mask and an `expression:` | +| `otherwise` | required. The value at every coordinate the cases leave | ### The rules that keep the cases apart diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index c29c3400..6506cdd2 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -166,7 +166,7 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\nA quantity whose value varies by region is written as ``cases:`` over a\ndeclared ``dims:``, with an ``otherwise:`` for the rest \u2014 see the\nlanguage reference.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.", "properties": { "cases": { "additionalProperties": { diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index bd958a2d..2675ab92 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -98,7 +98,7 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[str]: - """A cased entry's declared frame rather than the union of its arms — a narrower arm broadcasts — and a plain entry's body.""" + """An entry's declared frame where it has one — a narrower arm or body broadcasts along the rest — else its body's.""" declared = schema.expressions[node.name].dims if declared is not None: return frozenset(declared) @@ -282,10 +282,13 @@ def check_schema(schema: Spec, program: Program) -> None: ) for ename, entry in program.expressions.items(): + block = schema.expressions[ename] + if block.dims is None: + continue + frame = frozenset(block.dims) if not isinstance(entry.expression, Cases): + _check_body_dims(entry.expression, schema, frame, f"Named expression '{ename}'") continue - block = schema.expressions[ename] - frame = frozenset(block.dims or []) for region, label in zip(entry.expression.regions, [*block.cases, None], strict=True): context = case_context(ename, label) if label is not None: @@ -335,6 +338,17 @@ def _check_value_dims(node: Expression, schema: Spec, frame: frozenset[str], con ) +def _check_body_dims(node: Expression, schema: Spec, frame: frozenset[str], context: str) -> None: + """A plain entry's body may only carry dims its declared frame does; fewer is constant along the rest.""" + got = dims_of(node, schema, context) + if not got <= frame: + raise DimensionError( + f'{context}: the body carries dims {sorted(got - frame)} outside the dims: {sorted(frame)}. ' + f'The dims: are the frame the quantity is read over, and the body cannot widen it: add ' + f'{sorted(got - frame)} to dims:, or take them out of the body.' + ) + + def _check_where_dims( mask: Mask | None, frame: frozenset[str], diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index b2ce37f4..48589cd6 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -24,7 +24,6 @@ from mathspec.program import ( Assumption, BooleanLiteral, - Cases, Constant, ConstraintDeclaration, DimensionDeclaration, @@ -231,9 +230,10 @@ def lower(schema: Spec) -> Program: def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: - """The dims an entry is read over, in declaration order: declared for a cased entry, the body's for a plain one.""" - if isinstance(entry.body, Cases): - return tuple(schema.expressions[name].dims or ()) + """The dims an entry is read over: the ``dims:`` it declares, as written, else the body's in declaration order.""" + declared = schema.expressions[name].dims + if declared is not None: + return tuple(declared) carried = dims_of(entry.body, schema, f"Named expression '{name}'") return tuple(d for d in schema.dimensions if d in carried) diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 5e3d09df..6f160d60 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -372,15 +372,19 @@ class ExpressionBlock(_StrictBlock): expression: sum(p * rate, over=generator) description: CO2 released, the quantity the cap bounds - A quantity whose value varies by region is written as ``cases:`` over a - declared ``dims:``, with an ``otherwise:`` for the rest — see the - language reference. + ``dims:`` declares the frame the quantity is read over. A plain entry may + leave it out, and its body then decides the frame; a body that carries a + dimension the frame does not name is refused, and one that carries fewer + is constant along the rest. A quantity whose value varies by region is + written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` + for the rest — see the language reference. """ _label: ClassVar[str] = 'a named expression' expression: Expression | None = None - #: The frame the cases are read over — required with them, refused without. + #: The frame the quantity is read over — required with ``cases:``, and + #: the body's own dims where a plain entry leaves it out. dims: list[str] | None = None #: The regions, keyed by the name labelling the row each prints; every ``when`` is proved apart from the others. cases: Annotated[dict[str, ExpressionCase], Field(min_length=1)] = {} @@ -409,12 +413,6 @@ def _one_form_or_the_other(self) -> Self: "case's body gives it, since a case may be a scalar while the condition selecting it is not." ) raise ValueError(msg) - if self.dims is not None and not self.cases: - msg = ( - '`dims:` is only for a named expression with `cases:`. Without them the dims fall ' - 'out of the body, and declaring a second answer is a second thing to keep true.' - ) - raise ValueError(msg) if self.cases and self.otherwise is None: msg = ( 'a `cases:` block needs an `otherwise:` — the value wherever no `when` holds, and ' @@ -446,9 +444,13 @@ def _as_written(self) -> str | dict[str, object]: written['otherwise'] = self.otherwise return written assert self.expression is not None - if self.description is None: + if self.description is None and self.dims is None: return self.expression - return {'expression': self.expression, 'description': self.description} + written = {'dims': list(self.dims)} if self.dims is not None else {} + written['expression'] = self.expression + if self.description is not None: + written['description'] = self.description + return written class AssumptionBlock(_StrictBlock): diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index b280a61b..62119d1c 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -15,7 +15,7 @@ from mathspec.program import Mask, RelationPairComparison from mathspec.resolution import Namespace from mathspec.validation import to_spec -from tests.fixtures import expression_of, override, schema_of, where_of +from tests.fixtures import DISPATCH_MODEL, expression_of, override, schema_of, where_of if TYPE_CHECKING: from mathspec.spec import Spec @@ -605,3 +605,41 @@ def test_names_read_takes_both_sides_of_a_relation_pair(): where = RelationPairComparison('from_bus', 'bus', 'to_bus', 'bus', '!=', ('line',)) assert Mask(where).names_read == {'from_bus', 'to_bus'}, 'a relation pair names both maps it compares' + + +# --------------------------------------------------------------------------- +# a named expression's declared frame +# --------------------------------------------------------------------------- + +#: A quantity that is one number per generator, read over every snapshot as well. +FRAMED = override( + DISPATCH_MODEL, + **{ + 'variables.build': {'dims': ['generator']}, + 'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'build * p_max'}, + }, +) + + +def test_a_declared_frame_is_the_frame_as_written(): + """A plain entry's frame was its body's, in declaration order; declared, it is the dims: as written.""" + spec = to_spec( + override(FRAMED, **{'expressions.limit': {'dims': ['generator', 'snapshot'], 'expression': 'build * p_max'}}) + ) + assert spec.program.expressions['limit'].dims == ('generator', 'snapshot') + assert spec.to_dict()['expressions']['limit']['dims'] == ['generator', 'snapshot'], 'and it round-trips' + + +def test_a_body_outside_its_declared_frame_is_refused(): + wide = override(DISPATCH_MODEL, **{'expressions.limit': {'dims': ['generator'], 'expression': 'p * p_max'}}) + with pytest.raises(DimensionError, match=r"the body carries dims \['snapshot'\] outside the dims: \['generator'\]"): + to_spec(wide) + + +def test_a_declared_frame_is_read_at_every_coordinate_where_the_body_is_narrower(): + """The row would repeat across `snapshot` on the body's own frame; the declared frame says that is meant.""" + row = {'constraints.capped': {'dims': ['snapshot', 'generator'], 'expression': 'limit <= 10'}} + assert to_spec(override(FRAMED, **row)).program.constraints['capped'].dims == ('snapshot', 'generator') + undeclared = override(FRAMED, **row, **{'expressions.limit': 'build * p_max'}) + with pytest.raises(DimensionError, match=r"would be repeated across \['snapshot'\]"): + to_spec(undeclared) diff --git a/tests/test_validation.py b/tests/test_validation.py index 0e8f46b8..c218c078 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1817,11 +1817,6 @@ def test_the_fallback_is_written_as_the_bare_value(self): ), pytest.param({'description': 'nothing at all'}, 'this has neither', id='neither'), pytest.param({'cases': OPENING, 'otherwise': 0}, '`cases:` needs a `dims:`', id='no-dims'), - pytest.param( - {'expression': 'load', 'dims': ['snapshot']}, - '`dims:` is only for a named expression with `cases:`', - id='dims-alone', - ), pytest.param( {'dims': ['snapshot', 'generator'], 'cases': OPENING}, 'a `cases:` block needs an `otherwise:`', From 42df14843be093cbf6506671be818b0fcf50ec44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:34:54 +0000 Subject: [PATCH 67/75] Add the changelog link for #741 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 749a0ee9..d8fc060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(language): a named expression may declare the frame it is read over +- feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) From 80b694d0c121413b35b6bea3128dea44b5f09e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:51:28 +0000 Subject: [PATCH 68/75] feat(language): a sum other files add terms to is declared as an expression with a frame and no body Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 1 + docs/howto/compose.md | 24 ++--- docs/reference/language/declarations.md | 50 +++++----- docs/reference/language/named.md | 8 ++ docs/reference/reading.md | 4 +- docs/several-files.md | 38 ++++---- schema/mathspec.schema.json | 2 +- src/mathspec/_expression_resolver.py | 2 +- src/mathspec/_where_resolver.py | 4 +- src/mathspec/advising.py | 17 +++- src/mathspec/composition.py | 92 ++++++++---------- src/mathspec/dimensions.py | 4 +- src/mathspec/lowering.py | 11 ++- src/mathspec/program.py | 6 +- src/mathspec/resolution.py | 16 +++- src/mathspec/spec.py | 30 ++++-- src/mathspec/typesetting/__init__.py | 10 +- src/mathspec/typesetting/legend.py | 4 +- tests/fixtures.py | 8 ++ tests/test_composition.py | 2 +- tests/test_terms.py | 122 +++++++++++++++++------- 21 files changed, 286 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e2626d..3b2096a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): a sum other files add terms to is declared as an expression with a frame and no body - feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index fc7b8fe2..1fa07ea7 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that lands on a name no fragment defines, reads or uses is refused | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a given expression with a `term` | the name is an `expressions:` block of one fragment, empty or bodied; its body becomes that body, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that names no fragment's block is refused | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 74ad2e68..9e667242 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -248,6 +248,11 @@ and every other field a named expression takes. The file reads the name as the whole sum, alone and composed, and the term is its part of it. A file that only reads the name writes no term. +The name is an `expressions:` block of one other file. Where that file has +nothing of its own to put in, it declares the sum with a frame and no body, +an [empty sum](named.md#expressions), and reads it as a column until the +terms arrive: + ```yaml # fleet.yaml adds a term dimensions: @@ -270,15 +275,14 @@ given: ``` ```yaml -# balance.yaml reads the sum +# balance.yaml declares the sum, and adds nothing to it dimensions: snapshot: { dtype: int } bus: { dtype: str } -given: - expressions: - injection: - dims: [snapshot, bus] - description: what the components put into a bus +expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus constraints: balance: dims: [snapshot, bus] @@ -292,23 +296,23 @@ held to degree two, as what reads the sum is. All of this is checked at load. The typeset legend lists the entry under _Given_ and names the term, and the math prints the term under _Definitions_ as its own line. -[`merge`](../../howto/compose.md#a-library-of-components) defines the name as -the definition one fragment writes under `expressions:`, if any, plus every -term by its name, in fragment-name order, and keeps each term as a named -expression of the composed spec. Nothing declares that the name is a sum: a -term adds to whatever the other files define, as a fragment's objective adds -to the objective, and a later merge adds to the composed definition the same -way. The file that defines the name does not opt in. It reads the name as its -own definition alone, and as the definition plus every term once composed; -whoever composes the files answers for that sum. A term has to land on a name another file -has: one that defines it, reads it with no term of its own, or uses it in its -math. Terms alone are refused, with the near miss named, since `merge` fills -a reading or extends a definition and never invents a name. A definition -written as `cases:` is refused, since it is summed as written: name the cased -body as its own expression, and define the name as that name. A cased term is -added like any other, by its name. The definition keeps its own description, -or takes the first a reader wrote. Two files that both define the name under -`expressions:` are refused as a collision, and the message names `term:`. +[`merge`](../../howto/compose.md#a-library-of-components) writes the name's +body as the owner's body, if it has one, plus every term by its name, in +fragment-name order, and keeps each term as a named expression of the +composed spec. An empty sum keeps its frame, so the composed spec holds the +terms to it. A term adds to whatever the owner wrote, as a fragment's +objective adds to the objective, and a later merge adds to the composed body +the same way. The owner does not opt in. A file with a body reads it alone, +and the body plus every term once composed; whoever composes the files +answers for that sum. A term has to land on an `expressions:` block another +file declares. Terms alone are refused, with the near miss named, since +`merge` fills or extends what a file declared and never invents a name. A +body written as `cases:` is refused, since it is summed as written: name the +cased body as its own expression, and define the name as that name. A cased +term is added like any other, by its name. The block keeps the owner's +description, or takes the first a reader wrote. Two files that both declare +the name under `expressions:` are refused as a collision, and the message +names `term:`. ## `constraints` diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index de20662c..c8b8e89d 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -43,6 +43,14 @@ expressions: description: the nominal capacity, the same in every snapshot ``` +An entry with a `dims:` and no body at all is an **empty sum**: a quantity +this file declares and other files add terms to, through +[`merge`](../../howto/compose.md#a-library-of-components). Alone, the file +reads it as a column over the frame, the way it reads a +[given expression](declarations.md#given-expressions), and the legend says +so. [A term a file adds](declarations.md#a-term-a-file-adds) is the other +half. + Where the objective or a constraint names it, the body is substituted there, and the [degree limit](expressions.md#where-a-product-of-two-variables-is-allowed) applies where it is read. Where nothing in the math names it, the entry is diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 4ae78559..bab70667 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -173,7 +173,9 @@ model this one is layered onto. An expression reads a given expression as a A given expression with a `term` is one this file adds to: `program.given.expressions[name].term` is the term: the `Named` node of the entry of `program.expressions` it names. The name is still one the program -reads and does not build. +reads and does not build. An empty sum, an `expressions:` entry with a frame +and no body, sits here too, with `owned` set: the program declares the name, +and what fills it is the terms other files add. ```python layer = to_spec( diff --git a/docs/several-files.md b/docs/several-files.md index 53d8b6ac..b631785a 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -11,9 +11,10 @@ other files. Do [your first spec](first-spec.md) first. ## The network -Make a file `network.yaml`. It balances every bus, and it reads the injection -at a bus under [`given:`](reference/language/declarations.md#given) rather -than defining it: +Make a file `network.yaml`. It balances every bus, and it declares the +injection at a bus as a sum with a frame and no body: what the components +put in is theirs to say, in +[a term](reference/language/declarations.md#a-term-a-file-adds) each. ```yaml title="network.yaml" description: Every bus is balanced in every snapshot. @@ -22,11 +23,10 @@ dimensions: snapshot: { dtype: int, description: dispatch periods } bus: { description: network nodes } -given: - expressions: - injection: - dims: [snapshot, bus] - description: what the components put into a bus, less what they take out +expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus, less what they take out constraints: balance: @@ -40,10 +40,10 @@ Check the file: python -m mathspec check network.yaml ``` -The check accepts it, and notes the expression it reads: +The check accepts it, and notes the sum with no body yet: ```text -expression 'injection' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces. +expression 'injection' is a sum this file declares and other files add terms to: merge() writes its body from their terms. Until then, the program reads it and does not build it. ``` ## The generators @@ -218,22 +218,22 @@ print(ms.to_markdown(spec, legend=False)) #### Definitions - **`generation`** + **`injection`** ```math - \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`consumption`** + **`generation`** ```math - \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`injection`** + **`consumption`** ```math - \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` #### Variable domains @@ -370,11 +370,11 @@ Merge the generators and the loads without the network: ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) ``` -`merge` refuses it. A term adds to a name another file has, and without the -network no file defines, reads or uses `injection`: +`merge` refuses it. A term adds to a name another file declares, and without +the network no file declares `injection`: ```text -fragments 'generators' and 'loads' add a term to 'injection', which no fragment defines, reads or uses. A term adds to a name another file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling. +fragments 'generators' and 'loads' add a term to 'injection', which no fragment declares. A term adds to a name another file declares under 'expressions:': declare it there, with a `dims:` and no body where the files add every term, or fix the spelling. ``` ## Where to next diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index c2242ac6..1cc10caf 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -166,7 +166,7 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``dims:`` with no body at all is an **empty sum**: a quantity this file\ndeclares and other files add terms to, through\n[`merge`][mathspec.composition.merge]. Alone, the file reads it as a\ncolumn over the frame, as it reads a given expression.", "properties": { "cases": { "additionalProperties": { diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index ed407ea1..dbeb5178 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -182,7 +182,7 @@ def _name(self, node: NameNode) -> Expression | None: """ if node.name in self.formals: return None - if node.name in self.ns.schema.expressions: + if node.name in self.ns.bodies: try: return cast('Expression', self.ns.named(node.name, self.context)) except SchemaError as e: diff --git a/src/mathspec/_where_resolver.py b/src/mathspec/_where_resolver.py index ba87eec3..d2d53f1b 100644 --- a/src/mathspec/_where_resolver.py +++ b/src/mathspec/_where_resolver.py @@ -323,9 +323,9 @@ def _plain(self, node: UnresolvedComparisonNode) -> _Plain | None: value = literal.value else: value = _side_name(right) - if value is not None and (value in ns.schema.expressions or ns.kind(value) == 'parameter'): + if value is not None and (value in ns.bodies or ns.kind(value) == 'parameter'): return None - if name is None or value is None or name in ns.schema.expressions: + if name is None or value is None or name in ns.bodies: return None return _Plain(name, node.op, value, quoted) diff --git a/src/mathspec/advising.py b/src/mathspec/advising.py index 21c0163e..16c03c4c 100644 --- a/src/mathspec/advising.py +++ b/src/mathspec/advising.py @@ -60,7 +60,15 @@ def _given(program: Program) -> list[Advice]: *(Advice('given', name, _given_note('parameter', name)) for name in given.parameters), *(Advice('given', name, _given_note('variable', name)) for name in given.variables), *( - Advice('given', name, _term_note(name) if block.term is not None else _given_note('expression', name)) + Advice( + 'given', + name, + _sum_note(name) + if block.owned + else _term_note(name) + if block.term is not None + else _given_note('expression', name), + ) for name, block in given.expressions.items() ), *(Advice('given', name, _given_note('row family', name)) for name in given.constraints), @@ -76,6 +84,13 @@ def _given_note(kind: str, name: str) -> str: ) +def _sum_note(name: str) -> str: + return ( + f"expression '{name}' is a sum this file declares and other files add terms to: merge() writes its " + f'body from their terms. Until then, the program reads it and does not build it.' + ) + + def _term_note(name: str) -> str: return ( f"expression '{name}' is read here and declared elsewhere, and this file adds a term to it: merge() " diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 3b02849d..47239294 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -26,15 +26,16 @@ both named. * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. -* **A term is added to the definition it names.** A ``given: expressions:`` +* **A term is added to the expression it names.** A ``given: expressions:`` entry with a ``term:`` names the expression its fragment adds to the name. - The composed spec defines the name as the definition one fragment writes, - if any, plus every term by its name, in the fragments' name order, and keeps - each term as the named expression its fragment declares. A definition - written as ``cases:`` is refused, since it is summed as written. A later - merge adds to the composed definition the same way. Terms that land - on a name no fragment defines, reads or uses are refused: merge fills a - reading or extends a definition, and never invents a name. + The name is an ``expressions:`` block of one other fragment: a sum with a + frame and no body, or a definition. The composed spec writes its body as + that body, if it has one, plus every term by its name, in the fragments' + name order, and keeps each term as the named expression its fragment + declares. A definition written as ``cases:`` is refused, since it is summed + as written. A later merge adds to the composed body the same way. A term + that names no ``expressions:`` block of any fragment is refused: merge + fills or extends what a file declared, and never invents a name. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, @@ -88,7 +89,6 @@ from mathspec._yaml import read_spec from mathspec.dimensions import dims_of from mathspec.errors import LanguageError, did_you_mean, schema_error -from mathspec.program import Variable, walk from mathspec.spec import GivenBlock, Spec from mathspec.validation import to_spec @@ -324,13 +324,14 @@ def _summed( defined: Mapping[str, object], readings: Mapping[str, object], ) -> dict[str, object]: - """Every name a fragment adds a term to, defined as the definition plus the terms. + """Every name a fragment adds a term to, its body written as the owner's body plus the terms. - The definition one fragment writes comes first, in parentheses where it - is more than a name, then every term by its name in the fragments' name - order; one body alone is carried as written. A definition written as + The owner's body comes first where it has one, in parentheses where it is + more than a name, then every term by its name in the fragments' name + order; one body alone is carried as written. An empty sum keeps its frame, + so the composed load holds the terms to it. A definition written as ``cases:`` is refused, since it is summed as written and a set of cases is - no one body. The definition keeps its own description, or takes the first + no one body. The block keeps the owner's description, or takes the first a reader wrote. """ summed: dict[str, object] = {} @@ -339,20 +340,22 @@ def _summed( terms = _terms(read, key) if not terms: continue - _landed(read, loaded, defined, key, [name for name, _ in terms]) + _landed(read, defined, key, [name for name, _ in terms]) bodies = [term for _, term in terms] + base = _as_mapping(defined[key]) + if base.get('cases'): + raise LanguageError( + f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " + f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " + f'one body: name the cased body as its own expression, and define {key!r} as that name.' + ) block: dict[str, object] = {} - if key in defined: - base = _as_mapping(defined[key]) - if base.get('cases'): - raise LanguageError( - f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " - f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " - f'one body: name the cased body as its own expression, and define {key!r} as that name.' - ) + if base.get('dims'): + block['dims'] = base['dims'] + if base.get('expression') is not None: bodies.insert(0, cast('str', base['expression'])) - if base.get('description'): - block['description'] = base['description'] + if base.get('description'): + block['description'] = base['description'] block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(_summand(body) for body in bodies) if 'description' not in block and entry.get('description'): block['description'] = entry['description'] @@ -362,37 +365,20 @@ def _summed( def _landed( read: Mapping[str, dict[str, object]], - loaded: Mapping[str, Spec], defined: Mapping[str, object], key: str, contributors: list[str], ) -> None: - """Refuse terms that land on a name no fragment owns. + """Refuse terms that name no ``expressions:`` block of any fragment. - A term adds to a name another file has: a definition under - ``expressions:``, a reading under ``given:`` with no term of its own, or a - use in its math. Terms alone would define a name nothing asked for, which - is what a mistyped name looks like, so the refusal names the near miss - among the names a term could land on, which a term is not. + A term adds to a name another file declares: a sum with a frame and no + body, or a definition. Terms alone would define a name nothing declared, + which is what a mistyped name looks like, so the refusal names the near + miss among the names a term could land on, which a term is not. """ if key in defined: return - for spec in loaded.values(): - reading = spec.given.expressions.get(key) - if reading is not None and reading.term is None: - return - program = spec.program - bodies = (entry.expression for entry in program.expressions.values()) - if any(isinstance(node, Variable) and node.name == key for node in walk(*program.roots, *bodies)): - return - known = { - name - for sections in read.values() - for name in ( - *_mapping(sections.get('expressions')), - *_mapping(_mapping(sections.get('given')).get('expressions')), - ) - } - {key} + known = {name for sections in read.values() for name in _mapping(sections.get('expressions'))} - {key} terms = { _mapping(entry).get('term') for sections in read.values() @@ -403,8 +389,9 @@ def _landed( who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" near = f' {hint}' if (hint := did_you_mean(key, known, listing=False)) else '' raise LanguageError( - f'{who} a term to {key!r}, which no fragment defines, reads or uses. A term adds to a name another ' - f"file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling.{near}" + f'{who} a term to {key!r}, which no fragment declares. A term adds to a name another file declares ' + f"under 'expressions:': declare it there, with a `dims:` and no body where the files add every term, " + f'or fix the spelling.{near}' ) @@ -489,9 +476,12 @@ def _fits( def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: - """The frame of the composed body of *key*: the definition's, where a fragment writes one, with every term's.""" + """The frame of the composed body of *key*: the frame its owner declares, else its body's with every term's.""" frame: set[str] = set() for spec in loaded.values(): + declared = spec.expressions[key].dims if key in spec.expressions else None + if declared is not None: + return frozenset(declared) if key in spec.program.expressions: frame |= set(spec.program.expressions[key].dims) given = spec.program.given.expressions.get(key) diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index 280cbfe4..bf9f9a51 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -51,6 +51,7 @@ WindowSum, children, ) +from mathspec.spec import empty_sums if TYPE_CHECKING: from mathspec.program import Program @@ -70,7 +71,8 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: return frozenset({**schema.parameters, **schema.given.parameters}[node.name].dims) if isinstance(node, Variable): - return frozenset({**schema.variables, **schema.given.variables, **schema.given.expressions}[node.name].dims) + columns = {**schema.variables, **schema.given.variables, **schema.given.expressions, **empty_sums(schema)} + return frozenset(columns[node.name].dims or ()) if isinstance(node, Dual): return frozenset({**schema.constraints, **schema.given.constraints}[node.constraint].dims) diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index e7104c73..21621ec9 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -52,6 +52,7 @@ resolve_expression_text, resolve_where_text, ) +from mathspec.spec import empty_sums from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: @@ -118,7 +119,10 @@ def lower(schema: Spec) -> Program: resolve_expression(body_ast, ns, context, errors, formals=formals) entries: dict[str, Named] = {} + empty = empty_sums(schema) for ename in schema.expressions: + if ename in empty: + continue node, refusals = ns.named_entry(ename) errors.extend(refusals) if node is not None: @@ -261,8 +265,11 @@ def lower(schema: Spec) -> Program: name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() }, expressions={ - name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name)) - for name, g in schema.given.expressions.items() + **{ + name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name)) + for name, g in schema.given.expressions.items() + }, + **{name: GivenDeclaration(tuple(e.dims or ()), e.description, owned=True) for name, e in empty.items()}, }, ), description=schema.description, diff --git a/src/mathspec/program.py b/src/mathspec/program.py index acb16f1c..f43dd710 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -617,7 +617,8 @@ class GivenDeclaration: The frame is the whole declaration. A consumer looks the name up in the model this one is layered onto, checks the frame against what it finds, - and refuses a name the host does not provide. + and refuses a name the host does not provide. An empty sum is the one + entry this program declares itself: its body is what the other files add. """ dims: tuple[str, ...] @@ -626,6 +627,9 @@ class GivenDeclaration: #: only reads the name: the [`Named`][] node of the entry the term names, #: read over at most ``dims``. term: Named | None = None + #: Whether this program declares the name itself, as an expression with a + #: frame and no body, and leaves the body to the files that add terms. + owned: bool = False @dataclass(frozen=True) diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 661770f5..9cf437ce 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -46,6 +46,7 @@ RelationDeclaration, carries_variable, ) +from mathspec.spec import empty_sums if TYPE_CHECKING: from collections.abc import Iterable @@ -71,6 +72,7 @@ class Namespace: __slots__ = ( '_loading', '_named', + 'bodies', 'constraints', 'dimensions', 'dtypes', @@ -86,11 +88,15 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - variables = {**schema.variables, **schema.given.variables, **schema.given.expressions} + empty = empty_sums(schema) + variables = {**schema.variables, **schema.given.variables, **schema.given.expressions, **empty} parameters = {**schema.parameters, **schema.given.parameters} - #: Every name an expression reads as a column: the variables, and the - #: given expressions, whose bodies another file holds. + #: Every name an expression reads as a column: the variables, the + #: given expressions, whose bodies another file holds, and the empty + #: sums, whose bodies other files add. self.variables = frozenset(variables) + #: The named expressions with a body, which is what a name resolves to. + self.bodies = frozenset(name for name in schema.expressions if name not in empty) self.parameters = frozenset(parameters) self.dimensions = frozenset(schema.dimensions) #: The declared constraint names, off the flat namespace: a bare name @@ -112,7 +118,7 @@ def __init__(self, schema: Spec) -> None: #: each leaf a where names, the way a relation leaf carries ``over``. self.leaf_dims: dict[str, tuple[str, ...]] = { **{p: tuple(pd.dims) for p, pd in parameters.items()}, - **{v: tuple(vd.dims) for v, vd in variables.items()}, + **{v: tuple(vd.dims or ()) for v, vd in variables.items()}, } #: named expression -> its resolved node, or ``None``, and its refusals; #: filled the first time anything reads the name. @@ -199,7 +205,7 @@ def _references(self, name: str) -> tuple[str, ...]: continue else: pending.extend(nested(node)) - names = (n.name for n in nodes(*arithmetic) if isinstance(n, NameNode) and n.name in self.schema.expressions) + names = (n.name for n in nodes(*arithmetic) if isinstance(n, NameNode) and n.name in self.bodies) return tuple(dict.fromkeys(names)) def kind(self, name: str) -> DeclarationKind | None: diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index e720885d..0bc13de3 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -464,6 +464,11 @@ class ExpressionBlock(_StrictBlock): is constant along the rest. A quantity whose value varies by region is written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` for the rest — see the language reference. + + ``dims:`` with no body at all is an **empty sum**: a quantity this file + declares and other files add terms to, through + [`merge`][mathspec.composition.merge]. Alone, the file reads it as a + column over the frame, as it reads a given expression. """ _label: ClassVar[str] = 'a named expression' @@ -486,11 +491,17 @@ def _from_string(cls, data: object) -> object: @model_validator(mode='after') def _one_form_or_the_other(self) -> Self: """One ``expression:``, or ``cases:`` with the ``otherwise:`` and ``dims:`` they need.""" - if bool(self.cases) == (self.expression is not None): - got = 'both' if self.cases else 'neither' + if self.cases and self.expression is not None: + msg = ( + 'a named expression is one `expression:` or a set of `cases:`, and this has both. ' + 'Cases are for a quantity whose value varies by region; one expression is everything else.' + ) + raise ValueError(msg) + if not self.cases and self.expression is None and self.dims is None: msg = ( - f'a named expression is one `expression:` or a set of `cases:`, and this has {got}. ' - f'Cases are for a quantity whose value varies by region; one expression is everything else.' + 'a named expression is one `expression:` or a set of `cases:`, and this has neither. ' + 'Cases are for a quantity whose value varies by region; one expression is everything else. ' + 'An entry with a `dims:` and no body is a sum other files add terms to.' ) raise ValueError(msg) if self.cases and self.dims is None: @@ -529,11 +540,11 @@ def _as_written(self) -> str | dict[str, object]: written['cases'] = {name: case.model_dump() for name, case in self.cases.items()} written['otherwise'] = self.otherwise return written - assert self.expression is not None - if self.description is None and self.dims is None: + if self.expression is not None and self.description is None and self.dims is None: return self.expression written = {'dims': list(self.dims)} if self.dims is not None else {} - written['expression'] = self.expression + if self.expression is not None: + written['expression'] = self.expression if self.description is not None: written['description'] = self.description return written @@ -1001,6 +1012,11 @@ def _lower(self) -> Spec: return self +def empty_sums(schema: Spec) -> dict[str, ExpressionBlock]: + """The named expressions of *schema* with a frame and no body: the sums other files add terms to.""" + return {name: e for name, e in schema.expressions.items() if e.expression is None and not e.cases} + + def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in [`FORMULATIONS`][] order — all of them where it names none. diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 6a85ebd8..16f97fe2 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -216,11 +216,13 @@ def typeset_declaration( } given_kind = next((kind for kind, group in givens.items() if name in group), None) if given_kind is not None: - msg = ( - f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " - f"this file reads it and does not build it. It prints in the legend, under 'Given', " - f'so call typeset() for the whole spec.' + what = ( + f"'{name}' is a sum other files add terms to, and it has no body yet" + if given.expressions.get(name) is not None and given.expressions[name].owned + else f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " + f'this file reads it and does not build it' ) + msg = f"{what}. It prints in the legend, under 'Given', so call typeset() for the whole spec." raise SchemaError(msg) return walk.format.equation(walk.line(name)) diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index b551c336..e7f42a27 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -173,7 +173,9 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + ( - f'an expression this file adds {fmt.mono(block.term.name)} to' + 'a sum other files add terms to' + if block.owned + else f'an expression this file adds {fmt.mono(block.term.name)} to' if block.term is not None else 'an expression another file defines' ), diff --git a/tests/fixtures.py b/tests/fixtures.py index dcc38478..1df5822b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -71,6 +71,14 @@ 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } +#: The network: it declares `injection` as a sum with a frame and no body, for +#: the component fragments to add their terms to, and balances it. +NETWORK: dict[str, Any] = { + 'dimensions': BUS_DIMS, + 'expressions': {'injection': {'dims': BUS_FRAME, 'description': INJECTION}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, +} + def varied(base: dict[str, Any], **patch: Any) -> dict[str, Any]: """A deep copy of ``base`` with dotted paths replaced, missing parents created. diff --git a/tests/test_composition.py b/tests/test_composition.py index a5e291f0..f5a64746 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -322,7 +322,7 @@ def test_a_whole_declaration_is_created_and_the_model_loads(): ), pytest.param({'parameters': {'co2': {'dtype': 'float'}}}, 'a parameter needs `dims`', id='short-of-its-frame'), pytest.param( - {'expressions': {'spend': {'dims': ['snapshot']}}}, + {'expressions': {'spend': {'description': 'what is spent'}}}, 'one `expression:` or a set of `cases:`', id='short-of-what-it-says', ), diff --git a/tests/test_terms.py b/tests/test_terms.py index f0f6f48a..1b6647d2 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -28,7 +28,7 @@ typeset_declaration, ) from mathspec.program import Named, Variable, walk -from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION +from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION, NETWORK #: A generator fleet: what it puts in is its term. FLEET = { @@ -62,8 +62,8 @@ 'expressions': {'store_injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, } -#: A network that defines the injection itself, as its slack, and reads it. -NETWORK = { +#: A network that defines the injection with a body of its own, its slack, and reads it. +SLACKED = { 'dimensions': BUS_DIMS, 'variables': {'slack': {'dims': BUS_FRAME}}, 'expressions': {'injection': {'expression': 'slack', 'description': 'the slack, and what the components add'}}, @@ -85,6 +85,31 @@ def _demand(term: str = 'demand_injection', body: object = '-load', **fields: ob # --------------------------------------------------------------------------- +def test_an_empty_sum_loads_alone_and_reads_as_a_column(): + """The owner declares the name with a frame and no body; alone, its math reads a column nothing defines yet.""" + program = to_spec(NETWORK).program + assert 'injection' not in program.expressions, 'no body, so no definition' + sum_ = program.given.expressions['injection'] + assert sum_.owned and sum_.dims == ('snapshot', 'bus') and sum_.description == INJECTION + assert program.constraints['balance'].dims == ('snapshot', 'bus'), 'the row reads it over the frame' + + +def test_an_empty_sum_needs_a_frame(): + with pytest.raises(LanguageError, match=r'this has neither.*An entry with a `dims:` and no body is a sum'): + to_spec({**NETWORK, 'expressions': {'injection': {'description': INJECTION}}}) + + +def test_an_empty_sum_round_trips(): + assert to_spec(NETWORK).to_dict()['expressions']['injection'] == {'dims': BUS_FRAME, 'description': INJECTION} + + +def test_the_advice_says_other_files_fill_the_sum(): + (note,) = [note for note in advice(NETWORK) if note.kind == 'given'] + assert note.subject == 'injection' + assert 'a sum this file declares and other files add terms to' in note.text + assert 'merge()' in note.text + + def test_a_contributor_loads_alone_and_its_term_is_its_named_expression(): program = to_spec(FLEET).program term = program.given.expressions['injection'].term @@ -102,7 +127,7 @@ def test_a_contributor_reads_the_name_as_the_whole_sum(): """Alone and composed the file reads one thing, so nothing has to refuse a file that adds and reads.""" reads = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} assert to_spec(reads).program.constraints['capped'].dims == ('snapshot', 'bus') - composed = merge({'balance': BALANCE, 'fleet': reads, 'demand': DEMAND}) + composed = merge({'network': NETWORK, 'fleet': reads, 'demand': DEMAND}) assert composed.constraints['capped'].expression == 'injection <= 10' assert composed.program.expressions['injection'].in_math, 'composed, the cap reads the sum of every term' @@ -166,7 +191,7 @@ def test_the_advice_says_the_file_adds_a_term(): def test_merging_adds_the_terms_by_name_in_fragment_name_order_and_keeps_them(): - composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'network': NETWORK}) assert composed.expressions['injection'].expression == 'demand_injection + generator_injection' assert composed.expressions['demand_injection'].expression == '-load', 'each term stays a named expression' assert composed.program.expressions['generator_injection'].description == 'what the generators put in' @@ -174,13 +199,13 @@ def test_merging_adds_the_terms_by_name_in_fragment_name_order_and_keeps_them(): def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): - one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + one = merge({'fleet': FLEET, 'demand': DEMAND, 'network': NETWORK}) + other = merge({'network': NETWORK, 'demand': DEMAND, 'fleet': FLEET}) assert one == other def test_a_term_is_added_to_the_definition_one_fragment_writes(): - composed = merge({'network': NETWORK, 'fleet': FLEET, 'demand': DEMAND}) + composed = merge({'network': SLACKED, 'fleet': FLEET, 'demand': DEMAND}) assert composed.expressions['injection'].expression == 'slack + demand_injection + generator_injection' assert composed.expressions['injection'].description == 'the slack, and what the components add', ( 'the definition keeps its own description' @@ -189,20 +214,20 @@ def test_a_term_is_added_to_the_definition_one_fragment_writes(): def test_the_file_that_defines_the_name_reads_the_extended_sum_once_composed(): """A contributor decides alone. The defining file does not opt in, and whoever composes answers for the sum.""" - assert to_spec(NETWORK).expressions['injection'].expression == 'slack' - composed = merge({'network': NETWORK, 'demand': DEMAND}) + assert to_spec(SLACKED).expressions['injection'].expression == 'slack' + composed = merge({'network': SLACKED, 'demand': DEMAND}) assert composed.constraints['balance'].expression == 'injection == 0' assert composed.expressions['injection'].expression == 'slack + demand_injection' def test_a_definition_that_is_more_than_a_name_is_bracketed(): - network = {**NETWORK, 'expressions': {'injection': 'slack - slack / 2'}} + network = {**SLACKED, 'expressions': {'injection': 'slack - slack / 2'}} composed = merge({'network': network, 'demand': DEMAND}) assert composed.expressions['injection'].expression == '(slack - slack / 2) + demand_injection' def test_the_sum_takes_the_readers_description(): - composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'network': NETWORK}) assert composed.program.expressions['injection'].description == INJECTION @@ -211,15 +236,15 @@ def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording_in_name_o capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} capped = {**capped, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} for fragments in ( - {'capped': capped, 'balance': BALANCE, 'fleet': FLEET}, - {'balance': BALANCE, 'capped': capped, 'fleet': FLEET}, + {'capped': capped, 'network': NETWORK, 'fleet': FLEET}, + {'network': NETWORK, 'capped': capped, 'fleet': FLEET}, ): assert merge(fragments).expressions['injection'].description == INJECTION, "the wording of 'balance'" def test_a_composed_spec_takes_more_terms_in_a_second_merge(): """A composed definition is one a fragment wrote, so a later term adds to it like any other.""" - shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + shipped = merge({'network': NETWORK, 'demand': DEMAND, 'fleet': FLEET}) extended = merge({'shipped': shipped, 'storage': STORAGE}) assert extended.expressions['injection'].expression == ( '(demand_injection + generator_injection) + store_injection' @@ -235,7 +260,7 @@ def test_a_cased_term_is_added_like_any_other(): 'otherwise': '0', } ) - composed = merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) + composed = merge({'network': NETWORK, 'demand': cased, 'fleet': FLEET}) assert composed.expressions['injection'].expression == 'demand_injection + generator_injection' assert composed.program.expressions['demand_injection'].in_math @@ -245,13 +270,13 @@ def test_a_cased_term_is_added_like_any_other(): [ pytest.param( {'fleet': FLEET, 'demand': DEMAND}, - r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines, reads or uses\. " + r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment declares\. " r'.*or fix the spelling\.$', id='terms-and-nothing-else-with-no-near-miss', ), pytest.param( { - 'balance': BALANCE, + 'network': NETWORK, 'fleet': {**FLEET, 'given': {'expressions': {'injecton': FLEET['given']['expressions']['injection']}}}, }, r"fragment 'fleet' adds a term to 'injecton', which no fragment.*Did you mean 'injection'\?", @@ -260,19 +285,31 @@ def test_a_cased_term_is_added_like_any_other(): ], ) def test_terms_that_land_on_no_name_are_refused(fragments, message): - """Merge fills a reading or extends a definition; it never invents a name, which is what a typo would ask for.""" + """Merge fills or extends a block a file declared; it never invents a name, which is what a typo would ask for.""" with pytest.raises(LanguageError, match=message): merge(fragments) -def test_a_term_lands_on_a_name_a_contributor_s_own_math_uses(): +def test_a_reading_is_no_place_for_a_term_to_land(): + """A file that reads the name, or uses it in its own math, has not declared it; only an `expressions:` block has.""" capped = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} - composed = merge({'fleet': capped, 'demand': DEMAND}) - assert composed.program.expressions['injection'].in_math + for fragments in ({'fleet': capped, 'demand': DEMAND}, {'balance': BALANCE, 'demand': DEMAND}): + with pytest.raises(LanguageError, match=r'which no fragment declares'): + merge(fragments) + + +def test_the_composed_sum_keeps_the_owner_s_frame(): + """The frame the owner declared holds the terms to it when the composed spec loads.""" + composed = merge({'network': NETWORK, 'fleet': FLEET, 'demand': DEMAND}) + assert composed.expressions['injection'].dims == BUS_FRAME + narrow = {**NETWORK, 'expressions': {'injection': {'dims': ['bus'], 'description': INJECTION}}} + narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} + with pytest.raises(LanguageError, match=r"the body carries dims \['snapshot'\] outside the dims: \['bus'\]"): + merge({'network': narrow, 'fleet': FLEET}) def test_one_term_alone_is_its_name(): - composed = merge({'balance': BALANCE, 'storage': STORAGE}) + composed = merge({'network': NETWORK, 'storage': STORAGE}) assert composed.expressions['injection'].expression == 'store_injection' @@ -284,7 +321,7 @@ def test_two_definitions_collide_and_the_message_names_the_term(): 'constraints': {'other_balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } with pytest.raises(LanguageError) as raised: - merge({'network': NETWORK, 'other': other}) + merge({'network': SLACKED, 'other': other}) message = str(raised.value) assert "both declare the expression 'injection'" in message assert '`term:` under `given: expressions:`' in message @@ -299,12 +336,12 @@ def test_two_terms_of_one_name_collide(): match=r"both declare the expression 'demand_injection', which a fragment adds to 'injection' as a term\. " r"A term shares one namespace.*name each fragment's term apart", ): - merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': twin}) + merge({'network': NETWORK, 'demand': DEMAND, 'fleet': twin}) def test_a_cased_definition_a_term_adds_to_is_refused(): cased = { - **NETWORK, + **SLACKED, 'parameters': {'on': {'dims': BUS_FRAME, 'dtype': 'bool'}}, 'expressions': { 'injection': {'dims': BUS_FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} @@ -323,13 +360,13 @@ def test_two_readers_that_disagree_about_the_frame_are_refused(): def test_a_term_over_fewer_dimensions_merges_where_another_carries_the_rest(): flat = {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}} - composed = merge({'balance': BALANCE, 'demand': flat, 'fleet': FLEET}) + composed = merge({'network': NETWORK, 'demand': flat, 'fleet': FLEET}) assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): wide = { - **NETWORK, + **SLACKED, 'dimensions': {**BUS_DIMS, 'carrier': {'dtype': 'str'}}, 'variables': {'slack': {'dims': [*BUS_FRAME, 'carrier']}}, } @@ -358,17 +395,30 @@ def test_the_legend_names_the_term_the_file_adds(): assert 'an expression this file adds `demand_injection` to' in given -@pytest.mark.parametrize('spec', [pytest.param(BALANCE, id='a-reader'), pytest.param(FLEET, id='a-contributor')]) -def test_a_given_expression_prints_no_line_of_its_own(spec): - """The term prints as the definition it is; the name it adds to prints in the legend, with or without a term.""" - with pytest.raises( - LanguageError, match=r"'injection' is a given expression, and a given declaration prints no line" - ): +def test_the_legend_says_the_owner_s_sum_has_no_body_yet(): + given = to_markdown(NETWORK).split('#### Given')[1] + assert '`injection`' in given and 'a sum other files add terms to' in given + + +READS_IT = r"'injection' is a given expression, and a given declaration prints no line" + + +@pytest.mark.parametrize( + ('spec', 'message'), + [ + pytest.param(BALANCE, READS_IT, id='a-reader'), + pytest.param(FLEET, READS_IT, id='a-contributor'), + pytest.param(NETWORK, r"'injection' is a sum other files add terms to, and it has no body yet", id='an-owner'), + ], +) +def test_a_given_expression_prints_no_line_of_its_own(spec, message): + """The term prints as the definition it is; the name it adds to prints in the legend, with or without a body.""" + with pytest.raises(LanguageError, match=message): typeset_declaration(spec, 'injection', 'latex') def test_the_composed_sum_prints_its_terms_by_name(): - composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'network': NETWORK}) assert typeset_declaration(composed, 'injection', 'typst', inline_expressions=False) == ( 'italic("injection")_(t,b) = upright("demand_injection")_(t,b) + italic("generator_injection")_(t,b) ' 'quad forall t in cal(T), b in cal(B)' @@ -378,4 +428,4 @@ def test_the_composed_sum_prints_its_terms_by_name(): @pytest.mark.parametrize('fmt', sorted(FORMATS)) def test_a_contributor_and_a_composition_print_in_every_format(fmt): assert typeset(DEMAND, fmt), f'{fmt} rendered nothing for the contributor' - assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition' + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'network': NETWORK}), fmt), f'{fmt}: the composition' From a7b416b9970ab40e610d515000d30b79267818c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:52:18 +0000 Subject: [PATCH 69/75] Add the changelog link for #742 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2096a8..52db3c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(language): a sum other files add terms to is declared as an expression with a frame and no body +- feat(language): a sum other files add terms to is declared as an expression with a frame and no body ([#742](https://github.com/energy-models/mathspec/pull/742)) - feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) From 8862a8a020dbb1f1a23af6290c5aa14ee65f8c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:00:41 +0000 Subject: [PATCH 70/75] The owner of each PyPSA sum declares it with a frame and no body Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- docs/examples/pypsa.md | 9 +++ docs/examples/pypsa/index.md | 24 +++---- docs/examples/pypsa/network.md | 19 +++--- docs/examples/pypsa/power_flow.md | 17 +++-- docs/examples/pypsa/settings.md | 105 +++++++++++++++--------------- examples/pypsa.yaml | 9 +++ examples/pypsa/network.yaml | 15 ++--- examples/pypsa/power_flow.yaml | 13 ++-- examples/pypsa/settings.yaml | 89 ++++++++++++------------- tests/test_pypsa_split.py | 29 +++++---- tools/gallery.py | 17 ++--- tools/pypsa_split.py | 42 ++++++------ 12 files changed, 200 insertions(+), 188 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5fa89599..f9aed1f3 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -10024,6 +10024,7 @@ Store_primary_energy: ```yaml primary_energy: + dims: [scenario, global_constraint] expression: Generator_primary_energy + StorageUnit_primary_energy + Store_primary_energy description: >- what a `primary_energy` row totals — weighted generator energy over the @@ -10076,6 +10077,7 @@ Store_operational_limit: ```yaml operational_limit: + dims: [scenario, global_constraint] expression: >- Generator_operational_limit + StorageUnit_operational_limit @@ -10117,6 +10119,7 @@ Link_transmission_volume_expansion: ```yaml transmission_volume_expansion: + dims: [scenario, global_constraint] expression: Line_transmission_volume_expansion + Link_transmission_volume_expansion description: >- what a `transmission_volume_expansion_limit` row totals — length times @@ -10153,6 +10156,7 @@ Link_transmission_expansion_cost: ```yaml transmission_expansion_cost: + dims: [scenario, global_constraint] expression: Line_transmission_expansion_cost + Link_transmission_expansion_cost description: >- what a `transmission_expansion_cost_limit` row totals — capital cost @@ -10233,6 +10237,7 @@ Store_tech_capacity_expansion: ```yaml tech_capacity_expansion: + dims: [global_constraint] expression: >- Generator_tech_capacity_expansion + Line_tech_capacity_expansion @@ -10363,6 +10368,7 @@ Store_opex: ```yaml scenario_opex: + dims: [scenario] expression: >- Generator_opex + Generator_commitment_opex @@ -10459,6 +10465,7 @@ Store_additions: ```yaml Carrier_additions: + dims: [period, carrier] expression: >- Generator_additions + Line_additions @@ -10665,6 +10672,7 @@ Transformer_injection: ```yaml Bus_injection: + dims: [scenario, snapshot, bus] expression: >- Generator_injection + Line_injection @@ -10712,6 +10720,7 @@ Transformer_angle_sum: ```yaml Cycle_angle_sum: + dims: [scenario, snapshot, cycle] expression: Line_angle_sum + Transformer_angle_sum description: >- the voltage angle differences around a cycle: every branch flow times diff --git a/docs/examples/pypsa/index.md b/docs/examples/pypsa/index.md index bf11286f..03d8da76 100644 --- a/docs/examples/pypsa/index.md +++ b/docs/examples/pypsa/index.md @@ -16,8 +16,9 @@ not declare, it states under puts something into a sum every component adds to, such as the bus balance or the operating cost, names its share as an expression of its own and adds it with a [`term`](../../reference/language/declarations.md#a-term-a-file-adds). -One fragment reads each sum with its description, so a term always lands. A -new component is one new file, and the network does not change. +One fragment declares each sum, with its frame and description and no body, +so a term always lands. A new component is one new file, and the network does +not change. The split is written by `tools/pypsa_split.py` from the one file and checked against it, so the two cannot drift. It is a proof of concept: a decision on @@ -29,16 +30,17 @@ Three kinds of file. A **component** owns PyPSA's class of that name: its dimension, its data, its columns, its rows, and its share of each sum. The committable classes, `Generator`, `Link` and `Process`, are cut by feature into a file each for the class, its commitment, its ramping and its maintenance, -and the three sets read alike because PyPSA's rows do. A **reader** owns a sum -and the row that reads it: the network reads `Bus_injection`, power flow reads -`Cycle_angle_sum`. **Settings** holds what every topic reads, the weightings -and the flags, and the readings of the totals whose own readers, the cost, the -carriers and the global constraints, a model may leave out. +and the three sets read alike because PyPSA's rows do. An **owner** declares +a sum with its frame and no body, and the row that reads it: the network +declares `Bus_injection`, power flow declares `Cycle_angle_sum`. **Settings** +holds what every topic reads, the weightings and the flags, and declares the +totals whose own readers, the cost, the carriers and the global constraints, a +model may leave out. ### The sums -| Sum | Over | Read in | The terms, by the fragment that adds each | +| Sum | Over | Declared in | The terms, by the fragment that adds each | | --- | --- | --- | --- | | `scenario_opex` | `scenario` | [settings](settings.md) | [`Generator_opex`](generator.md), [`Generator_commitment_opex`](generator_commitment.md), [`Link_opex`](link.md), [`Link_commitment_opex`](link_commitment.md), [`Process_opex`](process.md), [`Process_commitment_opex`](process_commitment.md), [`StorageUnit_opex`](storage_unit.md), [`Store_opex`](store.md) | | `Bus_injection` | `scenario, snapshot, bus` | [network](network.md) | [`Generator_injection`](generator.md), [`Line_injection`](line.md), [`Link_injection`](link.md), [`Load_injection`](load.md), [`Process_injection`](process.md), [`StorageUnit_injection`](storage_unit.md), [`Store_injection`](store.md), [`Transformer_injection`](transformer.md) | @@ -67,14 +69,14 @@ carriers and the global constraints, a model may leave out. | [link_maintenance](link_maintenance.md) | 5 | 4 | 13 | 9 | | | [link_ramping](link_ramping.md) | 5 | 0 | 6 | 14 | | | [load](load.md) | 3 | 0 | 0 | 1 | `Bus_injection` | -| [network](network.md) | 0 | 0 | 1 | 1 | | -| [power_flow](power_flow.md) | 0 | 0 | 1 | 1 | | +| [network](network.md) | 0 | 0 | 1 | 0 | | +| [power_flow](power_flow.md) | 0 | 0 | 1 | 0 | | | [process](process.md) | 21 | 3 | 9 | 12 | `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | | [process_commitment](process_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | | [process_maintenance](process_maintenance.md) | 5 | 4 | 13 | 9 | | | [process_ramping](process_ramping.md) | 5 | 0 | 6 | 14 | | | [security](security.md) | 2 | 0 | 8 | 10 | | -| [settings](settings.md) | 9 | 0 | 0 | 7 | | +| [settings](settings.md) | 9 | 0 | 0 | 0 | | | [storage_unit](storage_unit.md) | 34 | 5 | 20 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | | [store](store.md) | 27 | 3 | 10 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | | [transformer](transformer.md) | 19 | 4 | 11 | 4 | `Bus_injection`, `Cycle_angle_sum` | diff --git a/docs/examples/pypsa/network.md b/docs/examples/pypsa/network.md index 49726bbd..208ac7c0 100644 --- a/docs/examples/pypsa/network.md +++ b/docs/examples/pypsa/network.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # The network -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the buses and the balance at each of them, which reads `Bus_injection`. It reads `Bus_injection` under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the buses and the balance at each of them. It declares `Bus_injection` as a sum with a frame and no body, which every component adds its injection to. ```yaml @@ -18,14 +18,13 @@ dimensions: bus: description: network nodes -given: - expressions: - Bus_injection: - dims: [scenario, snapshot, bus] - description: >- - what every component puts into a bus, less what it takes out of it; - PyPSA writes each term into the balance, and a load on its right-hand - side +expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side constraints: Bus_nodal_balance: @@ -56,7 +55,7 @@ constraints: | Symbol | Meaning | |---|---| -| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression another file defines — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, a sum other files add terms to — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | #### Subject to diff --git a/docs/examples/pypsa/power_flow.md b/docs/examples/pypsa/power_flow.md index a7cc55df..a13900ec 100644 --- a/docs/examples/pypsa/power_flow.md +++ b/docs/examples/pypsa/power_flow.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Power flow -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: Kirchhoff's voltage law around each cycle, which reads `Cycle_angle_sum`. It reads `Cycle_angle_sum` under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: Kirchhoff's voltage law around each cycle. It declares `Cycle_angle_sum` as a sum with a frame and no body, which the lines and transformers add to. ```yaml @@ -18,13 +18,12 @@ dimensions: cycle: description: independent cycles of the passive network graph — the cycle basis, data prep -given: - expressions: - Cycle_angle_sum: - dims: [scenario, snapshot, cycle] - description: >- - the voltage angle differences around a cycle: every branch flow times - its cycle weight, and every transformer phase shift +expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift constraints: Kirchhoff_Voltage_Law: @@ -51,7 +50,7 @@ constraints: | Symbol | Meaning | |---|---| -| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression another file defines — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, a sum other files add terms to — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | #### Subject to diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index 25f0e613..413eefc3 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Settings -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the weightings, the risk preference and the flags every topic reads, and the readings of the totals whose readers may be left out. It reads `Carrier_additions`, `operational_limit`, `primary_energy`, `scenario_opex`, `tech_capacity_expansion`, `transmission_expansion_cost` and 1 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the weightings, the risk preference and the flags every topic reads. It declares the seven totals whose readers may be left out, `scenario_opex`, `Carrier_additions` and the five global-constraint sums, each with a frame and no body, for the components to add to. ```yaml @@ -76,52 +76,6 @@ parameters: dims: [scenario, global_constraint, snapshot] dtype: bool -given: - expressions: - primary_energy: - dims: [scenario, global_constraint] - description: >- - what a `primary_energy` row totals — weighted generator energy over the - snapshots it counts, less the charge left in weighted storage at the - close; the initial charge it is compared against is folded into the - row's constant - operational_limit: - dims: [scenario, global_constraint] - description: >- - what an `operational_limit` row totals — the weighted energy its - generators deliver over the snapshots it counts, plus what its - non-cyclic storage draws down; the initial charge it draws from is - folded into the row's constant - transmission_volume_expansion: - dims: [scenario, global_constraint] - description: >- - what a `transmission_volume_expansion_limit` row totals — length times - the chosen build of the row's branches - transmission_expansion_cost: - dims: [scenario, global_constraint] - description: >- - what a `transmission_expansion_cost_limit` row totals — capital cost - times the chosen build of the row's branches - tech_capacity_expansion: - dims: [global_constraint] - description: >- - what a `tech_capacity_expansion_limit` row totals — the chosen build of - the row's carrier-and-bus set - scenario_opex: - dims: [scenario] - description: >- - what a future costs to run — every operating term, weighted by the - snapshot's hours and its period, before the scenario's own weight; a - start and a stop cost what they cost, unweighted, as PyPSA adds them - (`optimize.py:414-429`) - Carrier_additions: - dims: [period, carrier] - description: >- - what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like - PyPSA, it sums only the components that carry a carrier attribute, so a - transformer, which has none, counts in no carrier - expressions: GlobalConstraint_energy_weight: description: >- @@ -142,6 +96,49 @@ expressions: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) expression: 1 otherwise: 0 + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches ``` #### Sets @@ -172,13 +169,13 @@ expressions: | Symbol | Meaning | |---|---| -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | -| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | -| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | -| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression another file defines — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | -| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression another file defines — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | -| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression another file defines — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, a sum other files add terms to — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, a sum other files add terms to — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, a sum other files add terms to — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | #### Definitions diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 513971c3..5d58754d 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -2071,6 +2071,7 @@ expressions: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) primary_energy: + dims: [scenario, global_constraint] expression: Generator_primary_energy + StorageUnit_primary_energy + Store_primary_energy description: >- what a `primary_energy` row totals — weighted generator energy over the @@ -2087,6 +2088,7 @@ expressions: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) operational_limit: + dims: [scenario, global_constraint] expression: >- Generator_operational_limit + StorageUnit_operational_limit @@ -2101,6 +2103,7 @@ expressions: Link_transmission_volume_expansion: expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) transmission_volume_expansion: + dims: [scenario, global_constraint] expression: Line_transmission_volume_expansion + Link_transmission_volume_expansion description: >- what a `transmission_volume_expansion_limit` row totals — length times @@ -2110,6 +2113,7 @@ expressions: Link_transmission_expansion_cost: expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) transmission_expansion_cost: + dims: [scenario, global_constraint] expression: Line_transmission_expansion_cost + Link_transmission_expansion_cost description: >- what a `transmission_expansion_cost_limit` row totals — capital cost @@ -2127,6 +2131,7 @@ expressions: Store_tech_capacity_expansion: expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) tech_capacity_expansion: + dims: [global_constraint] expression: >- Generator_tech_capacity_expansion + Line_tech_capacity_expansion @@ -2176,6 +2181,7 @@ expressions: + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) scenario_opex: + dims: [scenario] expression: >- Generator_opex + Generator_commitment_opex @@ -2209,6 +2215,7 @@ expressions: expression: >- sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) Carrier_additions: + dims: [period, carrier] expression: >- Generator_additions + Line_additions @@ -2290,6 +2297,7 @@ expressions: - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) Bus_injection: + dims: [scenario, snapshot, bus] expression: >- Generator_injection + Line_injection @@ -2310,6 +2318,7 @@ expressions: + sum(Transformer_phase_shift_weight, over=transformer) + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) Cycle_angle_sum: + dims: [scenario, snapshot, cycle] expression: Line_angle_sum + Transformer_angle_sum description: >- the voltage angle differences around a cycle: every branch flow times diff --git a/examples/pypsa/network.yaml b/examples/pypsa/network.yaml index 87ea1317..73869218 100644 --- a/examples/pypsa/network.yaml +++ b/examples/pypsa/network.yaml @@ -11,14 +11,13 @@ dimensions: bus: description: network nodes -given: - expressions: - Bus_injection: - dims: [scenario, snapshot, bus] - description: >- - what every component puts into a bus, less what it takes out of it; - PyPSA writes each term into the balance, and a load on its right-hand - side +expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side constraints: Bus_nodal_balance: diff --git a/examples/pypsa/power_flow.yaml b/examples/pypsa/power_flow.yaml index b7edbe54..042c13f4 100644 --- a/examples/pypsa/power_flow.yaml +++ b/examples/pypsa/power_flow.yaml @@ -11,13 +11,12 @@ dimensions: cycle: description: independent cycles of the passive network graph — the cycle basis, data prep -given: - expressions: - Cycle_angle_sum: - dims: [scenario, snapshot, cycle] - description: >- - the voltage angle differences around a cycle: every branch flow times - its cycle weight, and every transformer phase shift +expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift constraints: Kirchhoff_Voltage_Law: diff --git a/examples/pypsa/settings.yaml b/examples/pypsa/settings.yaml index 6155e331..e8e19610 100644 --- a/examples/pypsa/settings.yaml +++ b/examples/pypsa/settings.yaml @@ -69,52 +69,6 @@ parameters: dims: [scenario, global_constraint, snapshot] dtype: bool -given: - expressions: - primary_energy: - dims: [scenario, global_constraint] - description: >- - what a `primary_energy` row totals — weighted generator energy over the - snapshots it counts, less the charge left in weighted storage at the - close; the initial charge it is compared against is folded into the - row's constant - operational_limit: - dims: [scenario, global_constraint] - description: >- - what an `operational_limit` row totals — the weighted energy its - generators deliver over the snapshots it counts, plus what its - non-cyclic storage draws down; the initial charge it draws from is - folded into the row's constant - transmission_volume_expansion: - dims: [scenario, global_constraint] - description: >- - what a `transmission_volume_expansion_limit` row totals — length times - the chosen build of the row's branches - transmission_expansion_cost: - dims: [scenario, global_constraint] - description: >- - what a `transmission_expansion_cost_limit` row totals — capital cost - times the chosen build of the row's branches - tech_capacity_expansion: - dims: [global_constraint] - description: >- - what a `tech_capacity_expansion_limit` row totals — the chosen build of - the row's carrier-and-bus set - scenario_opex: - dims: [scenario] - description: >- - what a future costs to run — every operating term, weighted by the - snapshot's hours and its period, before the scenario's own weight; a - start and a stop cost what they cost, unweighted, as PyPSA adds them - (`optimize.py:414-429`) - Carrier_additions: - dims: [period, carrier] - description: >- - what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like - PyPSA, it sums only the components that carry a carrier attribute, so a - transformer, which has none, counts in no carrier - expressions: GlobalConstraint_energy_weight: description: >- @@ -135,3 +89,46 @@ expressions: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) expression: 1 otherwise: 0 + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches diff --git a/tests/test_pypsa_split.py b/tests/test_pypsa_split.py index af810ea3..b7838088 100644 --- a/tests/test_pypsa_split.py +++ b/tests/test_pypsa_split.py @@ -8,8 +8,9 @@ component adds to (the bus balance, the operating cost, the global constraints) is one each component adds a term to: a named expression of its own, such as `Generator_injection`, which the `term:` of its `given:` entry -names. One fragment reads each sum with its description. So a component is a -family of files, and leaving the family out leaves a whole model. +names. One fragment declares each sum, with its frame and description and no +body. So a component is a family of files, and leaving the family out leaves +a whole model. """ from __future__ import annotations @@ -58,9 +59,9 @@ def test_the_fragments_are_what_the_splitter_writes(model): #: What a model may leave out, as the fragment names or name prefixes it -#: drops. A component comes as a family; security reads the branches. A -#: reader of a sum no model goes without is not listed: leaving it -#: out leaves its terms nowhere to land, which the test below holds. +#: drops. A component comes as a family; security reads the branches. The +#: owner of a sum no model goes without is not listed: leaving it out leaves +#: its terms nowhere to land, which the test below holds. OPTIONAL = [ 'carrier', 'cost', @@ -92,15 +93,17 @@ def test_leaving_a_topic_out_leaves_a_whole_model(dropped): assert not merge(kept).program.given, f'nothing that stays reads what {dropped} declares' -def test_every_sum_is_read_with_its_description_in_one_fragment(model): - described = { +def test_every_sum_is_declared_empty_with_its_description_in_one_fragment(model): + declared = { name: sorted( - stem for stem, path in PATHS.items() if (g := to_spec(path).given.expressions.get(name)) and g.description + stem + for stem, path in PATHS.items() + if (e := to_spec(path).expressions.get(name)) and e.expression is None and not e.cases and e.description ) for name in model.sums } - assert described == {name: [SUM_HOME.get(name, 'settings')] for name in model.sums}, ( - 'the reader of a sum no model goes without describes it, and settings describes the rest' + assert declared == {name: [SUM_HOME.get(name, 'settings')] for name in model.sums}, ( + 'the reader of a sum no model goes without declares it, and settings declares the rest' ) @@ -111,10 +114,10 @@ def test_every_sum_is_read_with_its_description_in_one_fragment(model): pytest.param('power_flow', 'Cycle_angle_sum', id='kirchhoff-with-the-branches-kept'), ], ) -def test_leaving_out_a_reader_no_model_goes_without_is_refused(dropped, sum_name): - """Only the reader has the sum, so without it the terms land on no name rather than define one.""" +def test_leaving_out_the_owner_of_a_sum_no_model_goes_without_is_refused(dropped, sum_name): + """Only the owner declares the sum, so without it the terms land on no name rather than define one.""" kept = {name: path for name, path in PATHS.items() if name != dropped} - with pytest.raises(LanguageError, match=rf"add a term to '{sum_name}', which no fragment defines, reads or uses"): + with pytest.raises(LanguageError, match=rf"add a term to '{sum_name}', which no fragment declares"): merge(kept) diff --git a/tools/gallery.py b/tools/gallery.py index bfa779e1..0565b9c0 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -119,23 +119,24 @@ def fragment_block(path: Path, table_path: Path) -> str: def split_index_block() -> str: - """The PyPSA split's two tables: each sum with the fragment that reads it and the terms, and each fragment. + """The PyPSA split's two tables: each sum with the fragment that declares it and the terms, and each fragment. - Both are read off the fragments, so the index cannot name a term or a - reader the files no longer have. + Both are read off the fragments, so the index cannot name a term or an + owner the files no longer have. """ specs = {path.stem: to_spec(path) for path in sorted(PYPSA.glob('*.yaml'))} - readers: dict[str, tuple[str, tuple[str, ...]]] = {} + owners: dict[str, tuple[str, tuple[str, ...]]] = {} terms: dict[str, dict[str, str]] = {} for name, spec in specs.items(): for hub, entry in spec.given.expressions.items(): if entry.term is not None: terms.setdefault(hub, {})[name] = entry.term - elif entry.description: - readers[hub] = (name, tuple(entry.dims)) - sums = ['| Sum | Over | Read in | The terms, by the fragment that adds each |', '| --- | --- | --- | --- |'] + for hub, block in spec.expressions.items(): + if block.expression is None and not block.cases: + owners[hub] = (name, tuple(block.dims or ())) + sums = ['| Sum | Over | Declared in | The terms, by the fragment that adds each |', '| --- | --- | --- | --- |'] for hub, by_fragment in sorted(terms.items(), key=lambda item: -len(item[1])): - reader, dims = readers[hub] + reader, dims = owners[hub] cells = ', '.join(f'[`{term}`]({fragment}.md)' for fragment, term in sorted(by_fragment.items())) sums.append(f'| `{hub}` | `{", ".join(dims)}` | [{reader}]({reader}.md) | {cells} |') files = [ diff --git a/tools/pypsa_split.py b/tools/pypsa_split.py index 7dc4ef2f..f194818f 100644 --- a/tools/pypsa_split.py +++ b/tools/pypsa_split.py @@ -14,8 +14,9 @@ `Generator_injection + … + Transformer_injection`, and `Bus_nodal_balance` reads `Bus_injection == 0`. A fragment owns the declarations of its topic, its terms among them, and reads what another topic declares under `given:`; the -entry for a hub names the fragment's term. One fragment reads each hub with its -description, so a term always lands, and a new component is one new fragment. +entry for a hub names the fragment's term. One fragment declares each hub, +with its frame and its description and no body, so a term always lands, and a +new component is one new fragment. `merge` then writes each hub as the file does, so `check` is one comparison: the merged fragments and the one file have one canonical form. @@ -100,9 +101,9 @@ 'transmission_expansion_cost', ) -#: The fragment that reads a hub with its description: the reader where a -#: model without it is never wanted, so the terms land nowhere without it, and -#: `settings` where the reader may go. +#: The fragment that declares a hub, with its frame and description and no +#: body: the reader where a model without it is never wanted, so the terms +#: land nowhere without it, and `settings` where the reader may go. SUM_HOME = {'Bus_injection': 'network', 'Cycle_angle_sum': 'power_flow'} #: The component each topic that adds a term is named after. @@ -237,7 +238,7 @@ def frames(self) -> dict[str, tuple[str, ...]]: return {name: e.dims for name, e in to_spec(self.data).program.expressions.items()} def home(self, name: str) -> str: - """The fragment that reads the hub *name* with its description.""" + """The fragment that declares the hub *name*, with its frame and description and no body.""" return SUM_HOME.get(name, 'settings') @@ -267,12 +268,13 @@ def fragments(model: Model) -> dict[str, str]: read = set().union( *(model.names_in(model.data[s][n]) for s, n in mine), *map(model.names_in, terms[name]), homes_here, adds ) - given = {model.key(n) for n in read if model.key(n)[0] not in FRAME and model.key(n) not in mine} + given = {model.key(n) for n in read - homes_here if model.key(n)[0] not in FRAME and model.key(n) not in mine} stated = { **{n: model.data[s][n]['dims'] for s, n in given if s in ('parameters', 'variables')}, **{n: list(frames[n]) for s, n in given if s == 'expressions'}, } - frame_reads = read | set().union(*(model.names_in(dims) for dims in stated.values())) + declared_frames = [*stated.values(), *(model.sums[hub]['dims'] for hub in homes_here)] + frame_reads = read | set().union(*(model.names_in(dims) for dims in declared_frames)) frame = {model.key(n) for n in frame_reads if model.key(n)[0] in FRAME} frame |= {model.key(n) for key in list(frame) for n in model.names_in(model.data[key[0]][key[1]])} written[name] = _fragment(model, mine | frame, given, stated, terms[name], adds, homes_here) @@ -320,14 +322,20 @@ def _fragment( adds: Mapping[str, str], homes: set[str], ) -> str: - """One fragment as YAML text, its sections and declarations in the order of the source file.""" + """One fragment as YAML text, its sections and declarations in the order of the source file. + + A hub the fragment is home to is written under ``expressions:`` with its + frame and description and no body: the sum the other fragments fill. + """ parts = [HEADER] for section in SECTIONS: blocks = [model.blocks[key] for key in model.keys if key[0] == section and key in included] + if section == 'expressions': + blocks += [_dumped(hub, model.sums[hub]) for hub in HUBS if hub in homes] if blocks: parts.append(f'{section}:\n' + '\n'.join(blocks) + '\n') if section == 'variables' and given: - parts.append('given:\n' + ''.join(_given(model, kind, given, stated, adds, homes) for kind in GIVEN_KINDS)) + parts.append('given:\n' + ''.join(_given(model, kind, given, stated, adds) for kind in GIVEN_KINDS)) if terms: objective = model.data['objective'] said = f' description: >-\n {objective["description"]}\n' if 'CVaR_omega' in ''.join(terms) else '' @@ -341,23 +349,13 @@ def _fragment( GIVEN_KINDS = {'parameters': ('dtype',), 'variables': ('domain',), 'expressions': ()} -def _given( - model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], adds: Mapping[str, str], homes: set[str] -) -> str: - """One kind of a fragment's `given:` block, an entry per line in source order: a term it adds, a hub's prose.""" +def _given(model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], adds: Mapping[str, str]) -> str: + """One kind of a fragment's `given:` block, an entry per line in source order, a term it adds named on its entry.""" names = [n for s, n in model.keys if s == kind and (s, n) in given] if not names: return '' lines = [f' {kind}:'] for n in names: - if kind == 'expressions' and n in homes: - entry = { - 'dims': stated[n], - **({'term': adds[n]} if n in adds else {}), - 'description': model.sums[n]['description'], - } - lines.append(_dumped(n, entry).replace('\n', '\n ').replace(f' {n}:', f' {n}:', 1)) - continue extra = {f: _entry(model.data[kind][n])[f] for f in GIVEN_KINDS[kind] if f in _entry(model.data[kind][n])} if kind == 'expressions' and n in adds: extra['term'] = adds[n] From 640f6ea051e7464e1be84ea706e9d1975f62e9cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:05:21 +0000 Subject: [PATCH 71/75] The golden model substitutes a signed sum into a plus Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- docs/reference/notation.md | 30 +++++++++++++++++++++++++++ tests/typesetting/golden/latex.out | 3 +++ tests/typesetting/golden/markdown.out | 13 ++++++++++++ tests/typesetting/golden/model.yaml | 6 ++++++ tests/typesetting/golden/typst.out | 3 +++ tools/notation.py | 2 ++ 6 files changed, 57 insertions(+) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 030bc436..31346b65 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -124,6 +124,7 @@ parameters: | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | +| $`\mathit{net}`$ | `net` over $`\mathcal{T}`$ — what a snapshot spills, less what it lacks | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{startup\_cost}`$ | `startup_cost` over $`\mathcal{T} \times \mathcal{G}`$ — what starting a unit in this snapshot costs, which the horizon's edge changes | @@ -630,6 +631,21 @@ constraints: \mathit{spend}_{t} \le \mathrm{budget} \qquad \forall\, t \in \mathcal{T} ``` +#### Signed sum substituted into a plus + +names the signed sum on the right of a plus: inlined, it prints as `- spill + slack`, not `+ -spill + slack` + +```yaml +constraints: + netted: + dims: [snapshot, bus] + expression: sum(p, by=gen_bus, over=generator, into=bus) + net == load +``` + +```math +\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{net}_{t} = \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} +``` + #### Cased expression in a constraint names the cased expression: its symbol prints here, its block once below @@ -659,6 +675,20 @@ expressions: \mathit{spend}_{t} = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \qquad \forall\, t \in \mathcal{T} ``` +#### Plain expression that is a signed sum + +a plain expression whose body is a sum that opens with a minus + +```yaml +expressions: + net: + expression: -spill + slack +``` + +```math +\mathit{net}_{t} = -\mathit{spill}_{t} + \mathit{slack}_{t} \qquad \forall\, t \in \mathcal{T} +``` + #### Expression defined by cases a quantity defined by region: no two cases overlap, and `otherwise` is the rest diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c69496c8..0af69e83 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -61,6 +61,7 @@ \item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ \item[{$\mathit{spend}$}] \texttt{spend} over $\mathcal{T}$ --- what a snapshot's dispatch costs \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) +\item[{$\mathit{net}$}] \texttt{net} over $\mathcal{T}$ --- what a snapshot spills, less what it lacks \item[{$\mathit{marginal\_price}$}] \texttt{marginal\_price} over $\mathcal{T} \times \mathcal{B}$ \item[{$\mathrm{startup\_cost}$}] \texttt{startup\_cost} over $\mathcal{T} \times \mathcal{G}$ --- what starting a unit in this snapshot costs, which the horizon's edge changes \end{description} @@ -89,6 +90,7 @@ \text{budgeted} && \mathit{spend}_{t} & \le \mathrm{budget} && \forall\, t \in \mathcal{T} \\ \text{starts} && p_{t,g} & \le \mathrm{startup\_cost}_{t,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{balance} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} & = \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ +\text{netted} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{net}_{t} & = \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{ramp} && p_{t,g} - p_{t \ominus 1,g} & \le p_{t - 1,g} + \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{edges} && p_{t \boxminus_{0} 1,g} & \le p_{t \boxplus_{0} 1,g} + \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{ahead} && p_{t,g} & \le p_{t \oplus 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ @@ -144,6 +146,7 @@ \text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ +\text{net} && \mathit{net}_{t} & = -\mathit{spill}_{t} + \mathit{slack}_{t} && \forall\, t \in \mathcal{T} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \end{align} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 9d724f0f..250559e8 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -61,6 +61,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | +| $`\mathit{net}`$ | `net` over $`\mathcal{T}`$ — what a snapshot spills, less what it lacks | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{startup\_cost}`$ | `startup_cost` over $`\mathcal{T} \times \mathcal{G}`$ — what starting a unit in this snapshot costs, which the horizon's edge changes | @@ -104,6 +105,12 @@ p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} = \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` +**`netted`** + +```math +\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{net}_{t} = \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} +``` + **`ramp`** ```math @@ -412,6 +419,12 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathit{lcoe} = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} ``` +**`net`** + +```math +\mathit{net}_{t} = -\mathit{spill}_{t} + \mathit{slack}_{t} \qquad \forall\, t \in \mathcal{T} +``` + **`marginal_price`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index ebe77903..ad695782 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -144,6 +144,9 @@ expressions: description: what a snapshot's dispatch costs expression: sum(p * cost, over=generator) lcoe: sum(p * cost) / sum(p) # nothing in the math reads it, so its divisor may carry a variable + net: # a plain expression whose body is a sum that opens with a minus + description: what a snapshot spills, less what it lacks + expression: -spill + slack marginal_price: dual(balance) # the row dual of a constraint, the one builtin only an entry the math never reads may call startup_cost: # a quantity defined by region: no two cases overlap, and `otherwise` is the rest description: what starting a unit in this snapshot costs, which the horizon's edge changes @@ -163,6 +166,9 @@ constraints: balance: # sum over a relation dims: [snapshot, bus] expression: sum(p, by=gen_bus, over=generator, into=bus) + spill - slack == load + netted: # names the signed sum on the right of a plus: inlined, it prints as `- spill + slack`, not `+ -spill + slack` + dims: [snapshot, bus] + expression: sum(p, by=gen_bus, over=generator, into=bus) + net == load ramp: # roll (cyclic) and shift (acyclic) in one equation dims: [snapshot, generator] expression: p - shift(p, along=snapshot, offset=1, edge='wrap') <= shift(p, along=snapshot, offset=1) + p_max diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index cd926e14..04fd1215 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -50,6 +50,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs / $italic("lcoe")$: `lcoe` (scalar) +/ $italic("net")$: `net` over $cal(T)$ --- what a snapshot spills, less what it lacks / $italic("marginal_price")$: `marginal_price` over $cal(T) times cal(B)$ / $upright("startup_cost")$: `startup_cost` over $cal(T) times cal(G)$ --- what starting a unit in this snapshot costs, which the horizon's edge changes @@ -76,6 +77,7 @@ $ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) + sum_ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in cal(T) \ upright("starts") & p_(t,g) & <= upright("startup_cost")_(t,g) & forall t in cal(T), g in cal(G) \ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) + italic("spill")_(t) - italic("slack")_(t) & = upright("load")_(t,b) & forall t in cal(T), b in cal(B) \ + upright("netted") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) + italic("net")_(t) & = upright("load")_(t,b) & forall t in cal(T), b in cal(B) \ upright("ramp") & p_(t,g) - p_(t minus.o 1,g) & <= p_(t - 1,g) + upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("edges") & p_(t minus.square_(0) 1,g) & <= p_(t plus.square_(0) 1,g) + upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("ahead") & p_(t,g) & <= p_(t plus.o 1,g) & forall t in cal(T), g in cal(G) \ @@ -130,6 +132,7 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in $ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ + upright("net") & italic("net")_(t) & = -italic("spill")_(t) + italic("slack")_(t) & forall t in cal(T) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ diff --git a/tools/notation.py b/tools/notation.py index 50510746..bbcf55e9 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -102,8 +102,10 @@ }, 'Named expressions': { 'budgeted': 'Plain expression in a constraint', + 'netted': 'Signed sum substituted into a plus', 'starts': 'Cased expression in a constraint', 'spend': 'Plain named expression', + 'net': 'Plain expression that is a signed sum', 'startup_cost': 'Expression defined by cases', 'spend_cap': 'Data-only expression', 'capped': 'Named expression in a condition', From be6b55bd45c72b43537b5d35acc6a8fac59e35b6 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:34:13 +0200 Subject: [PATCH 72/75] docs: a file restates a shared dimension as its dtype alone, and merge carries the one description written for it (#743) --- CHANGELOG.md | 1 + docs/several-files.md | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52db3c79..aa8ad280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- docs: a file restates a shared dimension as its dtype alone, and `merge` carries the one description written for it ([#743](https://github.com/energy-models/mathspec/pull/743)) - feat(language): a sum other files add terms to is declared as an expression with a frame and no body ([#742](https://github.com/energy-models/mathspec/pull/742)) - feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) diff --git a/docs/several-files.md b/docs/several-files.md index b631785a..5e60c2c2 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -51,14 +51,16 @@ expression 'injection' is a sum this file declares and other files add terms to: Make a file `generators.yaml`. It says what the fleet puts into a bus as a named expression, `generation`. It reads the injection too, and a [`term:`](reference/language/declarations.md#a-term-a-file-adds) on the entry -names `generation` as what this file adds to it. +names `generation` as what this file adds to it. The two dimensions it shares +with the network it restates as a dtype and nothing else: a description is not +a claim, and `merge` carries the network's. ```yaml title="generators.yaml" description: A generator fleet, each unit on one bus. dimensions: - snapshot: { dtype: int, description: dispatch periods } - bus: { description: network nodes } + snapshot: { dtype: int } + bus: { dtype: str } generator: { description: generating units } relations: @@ -148,8 +150,8 @@ bus: description: The demand at every bus. dimensions: - snapshot: { dtype: int, description: dispatch periods } - bus: { description: network nodes } + snapshot: { dtype: int } + bus: { dtype: str } parameters: demand: { dims: [snapshot, bus], description: demand to be met } @@ -185,13 +187,16 @@ Merge the three files in Python. Each name is what an error calls that file: ```python spec = ms.merge({'network': 'network.yaml', 'generators': 'generators.yaml', 'loads': 'loads.yaml'}) print(spec.expressions['injection'].expression) +print(spec.dimensions['snapshot'].description) ``` The injection is the sum of the two terms by name, in the order of the file -names. Each term stays a named expression of the merged spec: +names. Each term stays a named expression of the merged spec. The dimension +carries the one description written for it, the network's: ```text generation + consumption +dispatch periods ``` Print the math of the merged spec: @@ -253,8 +258,8 @@ term to the objective: description: Power bought from outside the network, at a price. dimensions: - snapshot: { dtype: int, description: dispatch periods } - bus: { description: network nodes } + snapshot: { dtype: int } + bus: { dtype: str } parameters: import_limit: { dims: [bus], description: most a bus can import } @@ -309,8 +314,8 @@ Make a file `emissions.yaml`. It caps what the fleet emits, and it reads description: A cap on what the fleet emits over the horizon. dimensions: - snapshot: { dtype: int, description: dispatch periods } - generator: { description: generating units } + snapshot: { dtype: int } + generator: { dtype: str } given: variables: From eebbe38ee9f72838b1abfbf237d0dc1558f22248 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 10:06:00 +0000 Subject: [PATCH 73/75] An empty sum prints as a definition with an ellipsis for its body Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- docs/reference/language/declarations.md | 4 +-- docs/reference/language/named.md | 7 ++--- docs/reference/typeset.md | 3 ++- src/mathspec/typesetting/__init__.py | 15 +++++------ src/mathspec/typesetting/format.py | 2 ++ src/mathspec/typesetting/latex.py | 1 + src/mathspec/typesetting/legend.py | 8 +++--- src/mathspec/typesetting/typst.py | 1 + src/mathspec/typesetting/walk.py | 34 +++++++++++++++++-------- tests/test_terms.py | 31 ++++++++++------------ tests/typesetting/test_golden.py | 5 +++- 11 files changed, 65 insertions(+), 46 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 9e667242..27e200e9 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -250,8 +250,8 @@ only reads the name writes no term. The name is an `expressions:` block of one other file. Where that file has nothing of its own to put in, it declares the sum with a frame and no body, -an [empty sum](named.md#expressions), and reads it as a column until the -terms arrive: +an [empty sum](named.md#expressions), reads it as a column until the terms +arrive, and prints its definition as `injection = ⋯`: ```yaml # fleet.yaml adds a term diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index c8b8e89d..9febb8f5 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -47,9 +47,10 @@ An entry with a `dims:` and no body at all is an **empty sum**: a quantity this file declares and other files add terms to, through [`merge`](../../howto/compose.md#a-library-of-components). Alone, the file reads it as a column over the frame, the way it reads a -[given expression](declarations.md#given-expressions), and the legend says -so. [A term a file adds](declarations.md#a-term-a-file-adds) is the other -half. +[given expression](declarations.md#given-expressions), and its definition +prints as `injection = ⋯`: the name is declared here, and the body is what +the other files add. [A term a file adds](declarations.md#a-term-a-file-adds) +is the other half. Where the objective or a constraint names it, the body is substituted there, and the [degree limit](expressions.md#where-a-product-of-two-variables-is-allowed) diff --git a/docs/reference/typeset.md b/docs/reference/typeset.md index 3a4fc7de..c684a079 100644 --- a/docs/reference/typeset.md +++ b/docs/reference/typeset.md @@ -55,7 +55,8 @@ a flag. The [Python API](api.md#typesetting) gives each signature. - A [named expression](language/named.md) prints its symbol where it is used and its body once, under a **Definitions** heading, in declaration order. A `cases:` block and a [reported entry](language/named.md#reported-expressions) - keep their definition line under either `inline_expressions` setting. + keep their definition line under either `inline_expressions` setting. An + [empty sum](language/named.md#expressions) prints last, as `symbol = ⋯`. - Wherever the math moves an index, which every `shift` does, the document prints a line saying what that notation means. - A file that does not load does not print. diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 16f97fe2..f9bc7313 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -204,25 +204,24 @@ def typeset_declaration( SchemaError: *name* is declared as none of the five, as two — a constraint may share a variable's name — or under ``given:``, which prints in the legend rather than as a line; or a symbol table entry - names nothing in the spec. + names nothing in the spec. A sum this file declares with no body + prints its line, ``symbol = ⋯``. """ walk = _walk(spec, fmt, symbols, inline_expressions=inline_expressions) given = walk.program.given givens = { 'parameter': given.parameters, 'variable': given.variables, - 'expression': given.expressions, + 'expression': {n: block for n, block in given.expressions.items() if not block.owned}, 'constraint': given.constraints, } given_kind = next((kind for kind, group in givens.items() if name in group), None) if given_kind is not None: - what = ( - f"'{name}' is a sum other files add terms to, and it has no body yet" - if given.expressions.get(name) is not None and given.expressions[name].owned - else f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " - f'this file reads it and does not build it' + msg = ( + f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " + f"this file reads it and does not build it. It prints in the legend, under 'Given', " + f'so call typeset() for the whole spec.' ) - msg = f"{what}. It prints in the legend, under 'Given', so call typeset() for the whole spec." raise SchemaError(msg) return walk.format.equation(walk.line(name)) diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index fe888114..ee7913b0 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -161,6 +161,8 @@ class Format(Protocol): operators: ClassVar[Mapping[OperatorName, str]] #: The em dash in prose: TeX and Typst read ``---`` as one, Markdown does not. dash: ClassVar[str] + #: The body of a sum other files add terms to, which this file leaves open. + ellipsis: ClassVar[str] # -- atoms ------------------------------------------------------------- diff --git a/src/mathspec/typesetting/latex.py b/src/mathspec/typesetting/latex.py index 3d6c9d80..ee83565b 100644 --- a/src/mathspec/typesetting/latex.py +++ b/src/mathspec/typesetting/latex.py @@ -47,6 +47,7 @@ class LatexFormat: notation: ClassVar[Notation] = 'latex' #: TeX's own em-dash ligature. dash: ClassVar[str] = '---' + ellipsis: ClassVar[str] = r'\cdots' operators: ClassVar[Mapping[OperatorName, str]] = {name: latex for name, (latex, _) in OPERATOR_SPELLINGS.items()} diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index e7f42a27..635aca98 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -173,15 +173,14 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + ( - 'a sum other files add terms to' - if block.owned - else f'an expression this file adds {fmt.mono(block.term.name)} to' + f'an expression this file adds {fmt.mono(block.term.name)} to' if block.term is not None else 'an expression another file defines' ), block.description, ) for g, block in program.given.expressions.items() + if not block.owned ), *( self._entry( @@ -193,9 +192,10 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str ), ] shown = set(defined) + owned = {e: block for e, block in program.given.expressions.items() if block.owned} definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) - for e, block in program.expressions.items() + for e, block in {**program.expressions, **owned}.items() if e in shown ] groups = ( diff --git a/src/mathspec/typesetting/typst.py b/src/mathspec/typesetting/typst.py index bdc52306..7d942aef 100644 --- a/src/mathspec/typesetting/typst.py +++ b/src/mathspec/typesetting/typst.py @@ -55,6 +55,7 @@ class TypstFormat: notation: ClassVar[Notation] = 'typst' #: Typst applies the same substitution TeX does. dash: ClassVar[str] = '---' + ellipsis: ClassVar[str] = 'dots.c' operators: ClassVar[Mapping[OperatorName, str]] = {name: typst for name, (_, typst) in OPERATOR_SPELLINGS.items()} diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 7f3b4f40..41620c09 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -239,7 +239,12 @@ def __init__( def _frame_of(self, name: str) -> list[str]: """The dims named expression *name* is read over, as its declaration carries them.""" - return list(self.program.expressions[name].dims) + entry = self.program.expressions.get(name) or self.program.given.expressions[name] + return list(entry.dims) + + def _owned(self) -> list[str]: + """The sums this file declares with a frame and no body, which other files add terms to.""" + return [name for name, block in self.program.given.expressions.items() if block.owned] def _op(self, name: OperatorName) -> str: return self.format.operators[name] @@ -702,7 +707,9 @@ def _definitions(self) -> list[Line]: quantity it names. Every declared one prints, used or not. Inlining substitutes away the plain ones the math reads; a ``cases`` block has no single body to substitute, and an entry the math never reads has - nowhere to be substituted *into*, so both still print. + nowhere to be substituted *into*, so both still print. A sum other + files add terms to prints last, as ``symbol = ⋯``: this file declares + the name and states no body for it. """ return [self.definition(name) for name in self.defined()] @@ -710,20 +717,27 @@ def defined(self) -> list[str]: """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining. Inlining leaves a name standing only where substitution cannot reach - it — a ``cases`` block, and an entry the objective and constraints - never read, which is a quantity reported back rather than solved for. + it — a ``cases`` block, an entry the objective and constraints never + read, which is a quantity reported back rather than solved for, and a + sum with no body yet. """ entries = self.program.expressions if not self.inline_expressions: - return list(entries) - return [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math] + return [*entries, *self._owned()] + standing = [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math] + return [*standing, *self._owned()] def definition(self, name: str) -> Line: - """The line defining one named expression, ``symbol = body`` over its frame.""" - body = self.program.expressions[name].expression + """The line defining one named expression, ``symbol = body`` over its frame, or ``symbol = ⋯`` for a sum with no body yet.""" + entry = self.program.expressions.get(name) frame = self._frame_of(name) ctx = self._context(frame) - rendered = self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx) + if entry is None: + rendered = self.format.ellipsis + elif isinstance(entry.expression, Cases): + rendered = self.format.cases(self._arms(entry.expression, ctx)) + else: + rendered = self._expression(entry.expression, ctx) return Line( label=name, left=ctx.indexed(self.symbols.name[name], frame), @@ -745,7 +759,7 @@ def line(self, name: str) -> Line: """ program = self.program kinds = { - 'named expression': (program.expressions, self.definition), + 'named expression': ({*program.expressions, *self._owned()}, self.definition), 'constraint': (program.constraints, self._constraint), 'assumption': (program.assumptions, self._assumption), 'curve': (program.piecewise, self._piecewise), diff --git a/tests/test_terms.py b/tests/test_terms.py index 1b6647d2..c3f8eebb 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -395,25 +395,22 @@ def test_the_legend_names_the_term_the_file_adds(): assert 'an expression this file adds `demand_injection` to' in given -def test_the_legend_says_the_owner_s_sum_has_no_body_yet(): - given = to_markdown(NETWORK).split('#### Given')[1] - assert '`injection`' in given and 'a sum other files add terms to' in given - - -READS_IT = r"'injection' is a given expression, and a given declaration prints no line" +def test_the_owner_s_sum_prints_as_a_definition_with_no_body(): + """The owner declares the name, so it prints under Definitions as ``symbol = ⋯``, and not under Given.""" + rendered = to_markdown(NETWORK) + assert '#### Given' not in rendered, 'the file declares the sum rather than reading one another file defines' + assert '`injection` over' in rendered.split('#### Definitions')[1] + assert typeset_declaration(NETWORK, 'injection', 'latex') == ( + r'\mathit{injection}_{t,b} = \cdots \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' + ) -@pytest.mark.parametrize( - ('spec', 'message'), - [ - pytest.param(BALANCE, READS_IT, id='a-reader'), - pytest.param(FLEET, READS_IT, id='a-contributor'), - pytest.param(NETWORK, r"'injection' is a sum other files add terms to, and it has no body yet", id='an-owner'), - ], -) -def test_a_given_expression_prints_no_line_of_its_own(spec, message): - """The term prints as the definition it is; the name it adds to prints in the legend, with or without a body.""" - with pytest.raises(LanguageError, match=message): +@pytest.mark.parametrize('spec', [BALANCE, FLEET], ids=['a-reader', 'a-contributor']) +def test_a_given_expression_prints_no_line_of_its_own(spec): + """The term prints as the definition it is; the name it adds to prints in the legend.""" + with pytest.raises( + LanguageError, match=r"'injection' is a given expression, and a given declaration prints no line" + ): typeset_declaration(spec, 'injection', 'latex') diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 06bdaac0..9fd74179 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -197,9 +197,12 @@ def test_the_golden_model_calls_every_operator_in_the_language(): #: `test_a_model_with_no_objective_prints_the_rest` covers it; that model #: declares no parameter, which is the legend's convention note with nothing #: to quote. A refusal of the name asked for renders nothing, and -#: `test_declaration.py` pins both. +#: `test_declaration.py` pins both. An empty sum draws advice until its terms +#: arrive, and `check` accepts the fixture in silence, so the fixture declares +#: none; `test_terms.py` prints one. UNREACHABLE = { walk: { + 'rendered = self.format.ellipsis', 'return self.format.cases(self._arms(node, ctx)), _ATOM', 'assert_never(node)', 'assert_never(check)', From 695febeaaaadb1e0314b4969bd8c2ed194309036 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 10:08:17 +0000 Subject: [PATCH 74/75] The owner pages of the PyPSA split print their sums with an ellipsis Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB --- docs/examples/pypsa/network.md | 12 +++++- docs/examples/pypsa/power_flow.md | 12 +++++- docs/examples/pypsa/settings.md | 61 +++++++++++++++++++++++++------ 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/docs/examples/pypsa/network.md b/docs/examples/pypsa/network.md index 208ac7c0..f9935d6a 100644 --- a/docs/examples/pypsa/network.md +++ b/docs/examples/pypsa/network.md @@ -51,11 +51,11 @@ constraints: | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` — network nodes | -#### Given +#### Definitions | Symbol | Meaning | |---|---| -| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, a sum other files add terms to — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | #### Subject to @@ -64,4 +64,12 @@ constraints: ```math \mathit{Bus\_injection}_{\xi,t,n} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` + +#### Definitions + +**`Bus_injection`** + +```math +\mathit{Bus\_injection}_{\xi,t,n} = \cdots \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} +``` diff --git a/docs/examples/pypsa/power_flow.md b/docs/examples/pypsa/power_flow.md index a13900ec..fd9103e0 100644 --- a/docs/examples/pypsa/power_flow.md +++ b/docs/examples/pypsa/power_flow.md @@ -46,11 +46,11 @@ constraints: | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -#### Given +#### Definitions | Symbol | Meaning | |---|---| -| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, a sum other files add terms to — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | #### Subject to @@ -59,4 +59,12 @@ constraints: ```math \mathit{Cycle\_angle\_sum}_{\xi,t,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` + +#### Definitions + +**`Cycle_angle_sum`** + +```math +\mathit{Cycle\_angle\_sum}_{\xi,t,c} = \cdots \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} +``` diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index 413eefc3..05cd6504 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -165,24 +165,19 @@ expressions: | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | | $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | -#### Given - -| Symbol | Meaning | -|---|---| -| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, a sum other files add terms to — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | -| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, a sum other files add terms to — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | -| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, a sum other files add terms to — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | -| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | -| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, a sum other files add terms to — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | - #### Definitions | Symbol | Meaning | |---|---| | $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | | $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$ — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | #### Definitions @@ -197,4 +192,46 @@ expressions: ```math \mathit{last}_{\xi,g,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{\xi,g,t} \wedge \neg \mathrm{in}_{\xi,g,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T} ``` + +**`scenario_opex`** + +```math +\mathit{scenario\_opex}_{\xi} = \cdots \qquad \forall\, \xi \in \Xi +``` + +**`Carrier_additions`** + +```math +\mathit{Carrier\_additions}_{y,i} = \cdots \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} +``` + +**`primary_energy`** + +```math +\mathit{primary\_energy}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`operational_limit`** + +```math +\mathit{operational\_limit}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`tech_capacity_expansion`** + +```math +\mathit{tech\_capacity\_expansion}_{g} = \cdots \qquad \forall\, g \in \mathcal{G} +``` + +**`transmission_volume_expansion`** + +```math +\mathit{transmission\_volume\_expansion}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` + +**`transmission_expansion_cost`** + +```math +\mathit{transmission\_expansion\_cost}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} +``` From d69d0d8ddef74ca835a6232dc716afd7f45b2726 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:58:48 +0000 Subject: [PATCH 75/75] Keep main's patch case in test_composition, which #740 carried from an earlier #742 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2 --- tests/test_composition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_composition.py b/tests/test_composition.py index f5a64746..a5e291f0 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -322,7 +322,7 @@ def test_a_whole_declaration_is_created_and_the_model_loads(): ), pytest.param({'parameters': {'co2': {'dtype': 'float'}}}, 'a parameter needs `dims`', id='short-of-its-frame'), pytest.param( - {'expressions': {'spend': {'description': 'what is spent'}}}, + {'expressions': {'spend': {'dims': ['snapshot']}}}, 'one `expression:` or a set of `cases:`', id='short-of-what-it-says', ),