From dee5a9a2f651a389b8538692d631a20c789feede Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:49:51 +0200 Subject: [PATCH] docs: a link's delivery lags its flow, wrapping or losing what is in transit at the horizon's edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rung 16 states PyPSA's link `delay` and `cyclic_delay` in the parity gallery: a port's delivery is shift(…, offset=Link_output_delay, edge=…), and a cases: block on Link_output_cyclic_delay chooses edge='wrap' (cyclic) or edge=0 (lost). The language already had cases: (#168) and shift; nothing in src/ or the schema changes — this is a new example closing a parity row, so it documents rather than adds. Supersedes #75; the weighted-duration case is #299. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016jk5LAMCoiD39q4Xz4AMVw --- docs/examples/pypsa.md | 97 ++++++++++++++++++- examples/pypsa.yaml | 33 ++++++- examples/references/pypsa/references.json | 17 ++++ .../references/pypsa/rung_16_link_delay.py | 50 ++++++++++ examples/symbols/pypsa.yaml | 3 + 5 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 examples/references/pypsa/rung_16_link_delay.py diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 5245f47b..e7cf5865 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -760,12 +760,11 @@ def build(): -### Rung 9 — multi-link and delay +### Rung 9 — multi-link | PyPSA | status | note | | ---------------------------- | ------ | --------------------------------------------- | | [nodal balance, ports 1..n](#bus-nodal_balance) | done | one term over `link_output`, so a link of any number of output ports needs no further declaration (#124) | -| nodal balance, link delay | open | #75, a per-link edge kind | > ✔ `pypsa 1.3.0` solves this rung's network at objective `11714.4`, 92 rows. @@ -1107,6 +1106,89 @@ def build(): +### Rung 16 — link delay + +A source feeding two sinks over links whose energy arrives late. PyPSA's +`delay` lags a port's delivery by a number of snapshots, and `cyclic_delay` +says whether the flow still in transit at the horizon's edge wraps to the start +or is lost. The two are a per-link number and a per-link kind, so the balance +turns them on with a `cases:` block over `shift(…, offset=Link_output_delay, +edge=…)` — one arm wrapping (`edge='wrap'`), the other vacating (`edge=0`). + +This is the one rung whose `generators` weighting is uniform. PyPSA measures +`delay` in those units, so a uniform column makes a delay of `n` a shift of +exactly `n` snapshot positions, which a positional `shift` reproduces. Under a +non-uniform column PyPSA resamples by elapsed time rather than by position — a +shift that varies along the snapshot axis, above what `shift` states (#299). + +| PyPSA | status | note | +| ------------------------- | ------ | ----------------------------------------------- | +| [link `delay`, `cyclic_delay`](#bus-nodal_balance) | done | a `cases:` on `cyclic_delay` over `shift(offset=delay)`, at uniform `generators` weighting; supersedes #75 | + + +> ✔ `pypsa 1.3.0` solves this rung's network at objective `5262.5`, 52 rows. + +
+The network, as PyPSA code + +`rung_16_link_delay.py` + +```python +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 16: link delay — a source feeding two sinks over links whose energy arrives late, one wrapping cyclically and one losing what is still in transit at the horizon's edge.""" + +from __future__ import annotations + +from datetime import datetime + +#: Four hourly stamps. The `generators` weighting is uniform here, and only here +#: on the ladder, because PyPSA measures `delay` in those units: a uniform column +#: makes a delay of `n` a shift of exactly `n` snapshot positions, which is what a +#: positional `shift(offset=n)` reproduces. 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]} + +#: Each sink carries the same demand, so the only thing that separates their cost +#: is how each link treats the horizon's edge. +DEMAND = [20.0, 15.0, 25.0, 10.0] + + +def build(): + """A source, two delayed links, and two sinks, stated as the calls that build it. + + ``pipe_wrap`` delays by two snapshots and wraps cyclically, so every unit the + cheap source sends reaches its sink and the expensive backup stays dark. + ``pipe_lose`` delays by one and does not wrap, so the flow that would arrive + in the first snapshot is lost and that snapshot's demand falls to the backup. + The two links differ in both a per-link number (`delay`) and a per-link kind + (`cyclic_delay`), which is what the model's ``cases:`` block turns on. + """ + import pypsa + + n = pypsa.Network() + n.set_snapshots(SNAPSHOTS) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', 'source') + n.add('Bus', 'sink_wrap') + n.add('Bus', 'sink_lose') + n.add('Generator', 'spring', bus='source', p_nom=200, marginal_cost=5) + n.add('Generator', 'backup_wrap', bus='sink_wrap', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_lose', bus='sink_lose', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe_wrap', bus0='source', bus1='sink_wrap', p_nom=100, delay=2, cyclic_delay=True) + n.add('Link', 'pipe_lose', bus0='source', bus1='sink_lose', p_nom=100, delay=1, cyclic_delay=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 +``` + +
+ + ### Not on a rung | PyPSA | status | note | @@ -1187,6 +1269,8 @@ The model a plain `n.optimize()` builds, stated in one file. Every declaration i | $\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 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{p}^{\mathrm{set}}$ | `Generator_p_set` over $\mathcal{T} \times \mathcal{G}$ — a given output schedule; a generator without one has no row here | @@ -1291,6 +1375,8 @@ The model a plain `n.optimize()` builds, stated in one file. Every declaration i $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. + $\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, 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. ### Objective @@ -2780,7 +2866,8 @@ 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 at every port they deliver to, meets the load there. + them after losses and any delay at every port they deliver to, 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. foreach: [snapshot, bus] @@ -2789,13 +2876,13 @@ Bus_nodal_balance: + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus) + sum(Store_p, by=Store_bus) - sum(Link_p, by=Link_bus0) - + sum(at(Link_p, by=Link_output_link) * Link_efficiency, by=Link_output_bus) + + sum(Link_output_arrival, by=Link_output_bus) - sum(Line_s, by=Line_bus0) + sum(Line_s, by=Line_bus1) == sum(Load_p_set, by=Load_bus) ``` -$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{Generator\_bus}(g) = n} p_{t,g} + \sum_{s \in \mathcal{S} \thinspace:\thinspace \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{t,s} - h^{-}_{t,s} \right) + \sum_{v \in \mathcal{V} \thinspace:\thinspace \mathrm{Store\_bus}(v) = n} q_{t,v} - \left( \sum_{l \in \mathcal{L} \thinspace:\thinspace \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \thinspace:\thinspace \mathrm{Link\_output\_bus}(o) = n} f_{t,\mathrm{Link\_output\_link}(o)} \cdot \eta_{o} - \left( \sum_{k \in \mathcal{K} \thinspace:\thinspace \mathrm{Line\_bus0}(k) = n} s_{t,k} \right) + \sum_{k \in \mathcal{K} \thinspace:\thinspace \mathrm{Line\_bus1}(k) = n} s_{t,k} = \sum_{d \in \mathcal{D} \thinspace:\thinspace \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\thinspace t \in \mathcal{T},\enspace n \in \mathcal{N}$$ +$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{Generator\_bus}(g) = n} p_{t,g} + \sum_{s \in \mathcal{S} \thinspace:\thinspace \mathrm{StorageUnit\_bus}(s) = n} \left( h^{+}_{t,s} - h^{-}_{t,s} \right) + \sum_{v \in \mathcal{V} \thinspace:\thinspace \mathrm{Store\_bus}(v) = n} q_{t,v} - \left( \sum_{l \in \mathcal{L} \thinspace:\thinspace \mathrm{Link\_bus0}(l) = n} f_{t,l} \right) + \sum_{o \in \mathcal{O} \thinspace:\thinspace \mathrm{Link\_output\_bus}(o) = n} \overrightarrow{f}_{t,o} - \left( \sum_{k \in \mathcal{K} \thinspace:\thinspace \mathrm{Line\_bus0}(k) = n} s_{t,k} \right) + \sum_{k \in \mathcal{K} \thinspace:\thinspace \mathrm{Line\_bus1}(k) = n} s_{t,k} = \sum_{d \in \mathcal{D} \thinspace:\thinspace \mathrm{Load\_bus}(d) = n} \mathrm{load}_{t,d} \qquad \forall\thinspace t \in \mathcal{T},\enspace n \in \mathcal{N}$$ #### Variable domains diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 9ec5efee..006372e0 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -148,6 +148,20 @@ parameters: `efficiency2`, … read long — negative where that port consumes rather than delivers dims: [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 + dims: [link_output] + 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 + dims: [link_output] + dtype: bool Link_marginal_cost: description: cost of one unit of flow dims: [snapshot, link] @@ -637,6 +651,20 @@ expressions: when: not Store_e_cyclic AND position(snapshot) == 0 expression: Store_e_initial otherwise: Store_retention * shift(Store_e, over=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`; 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 + foreach: [snapshot, link_output] + cases: + wrapping: + when: Link_output_cyclic_delay + expression: shift(at(Link_p, by=Link_output_link) * Link_efficiency, over=snapshot, offset=Link_output_delay, edge='wrap') + otherwise: shift(at(Link_p, by=Link_output_link) * Link_efficiency, over=snapshot, offset=Link_output_delay, edge=0) primary_energy: description: >- what a `primary_energy` row totals — weighted generator energy, less @@ -1258,7 +1286,8 @@ 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 at every port they deliver to, meets the load there. + them after losses and any delay at every port they deliver to, 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. foreach: [snapshot, bus] @@ -1267,7 +1296,7 @@ constraints: + sum(StorageUnit_p_dispatch - StorageUnit_p_store, by=StorageUnit_bus) + sum(Store_p, by=Store_bus) - sum(Link_p, by=Link_bus0) - + sum(at(Link_p, by=Link_output_link) * Link_efficiency, by=Link_output_bus) + + sum(Link_output_arrival, by=Link_output_bus) - sum(Line_s, by=Line_bus0) + sum(Line_s, by=Line_bus1) == sum(Load_p_set, by=Load_bus) diff --git a/examples/references/pypsa/references.json b/examples/references/pypsa/references.json index 161d3176..dc60e459 100644 --- a/examples/references/pypsa/references.json +++ b/examples/references/pypsa/references.json @@ -565,5 +565,22 @@ "Link-fix-p-lower": 8, "Link-fix-p-upper": 8 } + }, + "rung_16_link_delay": { + "columns": { + "Generator-p": 12, + "Link-p": 8 + }, + "global_constraints": {}, + "objective": 5262.5, + "objective_constant": 0.0, + "pypsa": "1.3.0", + "rows": { + "Bus-nodal_balance": 12, + "Generator-fix-p-lower": 12, + "Generator-fix-p-upper": 12, + "Link-fix-p-lower": 8, + "Link-fix-p-upper": 8 + } } } diff --git a/examples/references/pypsa/rung_16_link_delay.py b/examples/references/pypsa/rung_16_link_delay.py new file mode 100644 index 00000000..c1f0345f --- /dev/null +++ b/examples/references/pypsa/rung_16_link_delay.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Rung 16: link delay — a source feeding two sinks over links whose energy arrives late, one wrapping cyclically and one losing what is still in transit at the horizon's edge.""" + +from __future__ import annotations + +from datetime import datetime + +#: Four hourly stamps. The `generators` weighting is uniform here, and only here +#: on the ladder, because PyPSA measures `delay` in those units: a uniform column +#: makes a delay of `n` a shift of exactly `n` snapshot positions, which is what a +#: positional `shift(offset=n)` reproduces. 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]} + +#: Each sink carries the same demand, so the only thing that separates their cost +#: is how each link treats the horizon's edge. +DEMAND = [20.0, 15.0, 25.0, 10.0] + + +def build(): + """A source, two delayed links, and two sinks, stated as the calls that build it. + + ``pipe_wrap`` delays by two snapshots and wraps cyclically, so every unit the + cheap source sends reaches its sink and the expensive backup stays dark. + ``pipe_lose`` delays by one and does not wrap, so the flow that would arrive + in the first snapshot is lost and that snapshot's demand falls to the backup. + The two links differ in both a per-link number (`delay`) and a per-link kind + (`cyclic_delay`), which is what the model's ``cases:`` block turns on. + """ + import pypsa + + n = pypsa.Network() + n.set_snapshots(SNAPSHOTS) + for column, values in WEIGHTINGS.items(): + n.snapshot_weightings[column] = values + n.add('Bus', 'source') + n.add('Bus', 'sink_wrap') + n.add('Bus', 'sink_lose') + n.add('Generator', 'spring', bus='source', p_nom=200, marginal_cost=5) + n.add('Generator', 'backup_wrap', bus='sink_wrap', p_nom=200, marginal_cost=100) + n.add('Generator', 'backup_lose', bus='sink_lose', p_nom=200, marginal_cost=100) + n.add('Link', 'pipe_wrap', bus0='source', bus1='sink_wrap', p_nom=100, delay=2, cyclic_delay=True) + n.add('Link', 'pipe_lose', bus0='source', bus1='sink_lose', p_nom=100, delay=1, cyclic_delay=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 diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 74cac60f..5be16db1 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -56,6 +56,8 @@ names: Link_p_min_pu: '\underline{\mathrm{f}}' Link_p_max_pu: '\overline{\mathrm{f}}' Link_efficiency: '\eta' + Link_output_delay: '\mathrm{d}^{f}' + Link_output_cyclic_delay: '\mathrm{cyc}^{f}' Link_marginal_cost: '\mathrm{c}^{f}' Load_p_set: '\mathrm{load}' Generator_p_set: '\mathrm{p}^{\mathrm{set}}' @@ -72,6 +74,7 @@ names: Generator_e_sum_max: '\overline{\mathrm{E}}' Link_p_nom_ext: "F" Link_p_nom_effective: '\widetilde{\mathrm{f}}^{\mathrm{nom}}' + Link_output_arrival: '\overrightarrow{f}' Link_p_nom_min: '\underline{\mathrm{f}}^{\mathrm{nom}}' Link_p_nom_max: '\overline{\mathrm{f}}^{\mathrm{nom}}' Link_capital_cost: '\mathrm{c}^{\mathrm{cap},f}'