PyPSA link delay — power that arrives later than it left¶
A shipment is the input shifted along time: withdrawn at one snapshot, delivered at another, derated on the way.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 4311.111111111111, matched to
rtol=1e-09.
Every other shift in the corpus relates a variable to itself: a ramp limit,
a state of charge. Here it relates two buses' balances. What port_a gives up
in snapshot 0 is what port_b receives in snapshot 2, times the link's
efficiency.
Two links serve the same demand, and the delay is a column, not a constant.
ship takes two snapshots and loses 10%. wire arrives at once and loses
nothing. The first two snapshots at port_b have nothing shipped to them yet,
so the expensive unit beside the load serves them. That is what makes the delay
cost something.
The model¶
The same model, as math
PyPSA's delayed link: power withdrawn at one snapshot arrives at a later one, so a shipment is the input shifted along time and derated by the link's efficiency. Two links serve the same demand — one takes two snapshots to arrive, one arrives at once — and the first snapshots, which nothing has reached yet, are served by the expensive unit standing beside the load. Optimum 4311.111111111111, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot — dispatch periods |
| \(\mathcal{B}\) | index \(b\) — bus with \(\mathrm{gen\_bus}: \mathcal{E} \to \mathcal{B},\ \mathrm{link\_from}: \mathcal{L} \to \mathcal{B},\ \mathrm{link\_to}: \mathcal{L} \to \mathcal{B}\) — network nodes |
| \(\mathcal{E}\) | index \(e\) — generator with \(\mathrm{gen\_bus}: \mathcal{E} \to \mathcal{B}\) — generating units, each sitting on one bus |
| \(\mathcal{L}\) | index \(l\) — link with \(\mathrm{link\_from}: \mathcal{L} \to \mathcal{B},\ \mathrm{link\_to}: \mathcal{L} \to \mathcal{B}\) — controllable connections, each joining two buses |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{p}^{\mathrm{nom}}\) | p_nom over \(\mathcal{E}\) — installed capacity of a generator |
| \(\mathrm{marginal\_cost}\) | marginal_cost over \(\mathcal{E}\) — cost of one unit of output |
| \(\mathrm{link\_p\_nom}\) | link_p_nom over \(\mathcal{L}\) — most a link may take in during one snapshot |
| \(\mathrm{efficiency}\) | efficiency over \(\mathcal{L}\) — share of what entered a link that arrives at the other end |
| \(\mathrm{delay}\) | delay over \(\mathcal{L}\) — how many snapshots a link takes to deliver what it took in |
| \(\mathrm{load}\) | load over \(\mathcal{T} \times \mathcal{B}\) — demand at each bus in each snapshot |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{E}\) — output of a generator in a snapshot |
| \(g\) | g over \(\mathcal{T} \times \mathcal{L}\) — what a link takes in during a snapshot, at the bus it leaves |
Upright is what the data supplies — a parameter such as \(\mathrm{p}^{\mathrm{nom}}\), a coordinate map, a label — and italic is what the solver chooses, such as \(p\). An index is italic too, being what a quantifier chooses, and a set is script.
\(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¶
Subject to¶
nodal_balance
Variable domains¶
p
g
The tabs start from the instance's tables — one frame per parameter.
description: >-
PyPSA's delayed link: power withdrawn at one snapshot arrives at a later one,
so a shipment is the input shifted along time and derated by the link's
efficiency. Two links serve the same demand — one takes two snapshots to
arrive, one arrives at once — and the first snapshots, which nothing has
reached yet, are served by the expensive unit standing beside the load.
Optimum 4311.111111111111, from PyPSA itself.
dimensions:
snapshot:
description: dispatch periods
dtype: int
bus:
description: network nodes
dtype: str
generator:
description: generating units, each sitting on one bus
dtype: str
link:
description: controllable connections, each joining two buses
dtype: str
relations:
gen_bus:
description: the bus a generator sits on
key: generator
values: bus
link_from:
description: the bus a link leaves
key: link
values: bus
link_to:
description: the bus a link arrives at
key: link
values: bus
parameters:
p_nom:
description: installed capacity of a generator
dims: [generator]
marginal_cost:
description: cost of one unit of output
dims: [generator]
link_p_nom:
description: most a link may take in during one snapshot
dims: [link]
efficiency:
description: share of what entered a link that arrives at the other end
dims: [link]
delay:
description: how many snapshots a link takes to deliver what it took in
dims: [link]
dtype: int
load:
description: demand at each bus in each snapshot
dims: [snapshot, bus]
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
bounds:
lower: 0
upper: p_nom
g:
description: what a link takes in during a snapshot, at the bus it leaves
dims: [snapshot, link]
bounds:
lower: 0
upper: link_p_nom
constraints:
nodal_balance:
description: >-
what is generated at a bus, plus what arrives over the links — the input
of `delay` snapshots ago, derated — less what the links took in there,
meets the load. `edge=0` is what a non-cyclic delay means: a snapshot
earlier than a link's delay receives nothing over it, there being no such
snapshot to have taken anything in.
dims: [snapshot, bus]
expression: >-
sum(p, by=gen_bus, over=generator, into=bus)
+ sum(shift(g, along=snapshot, offset=delay, edge=0) * efficiency, by=link_to, over=link, into=bus)
- sum(g, by=link_from, over=link, into=bus)
== load
objective:
sense: minimize
description: what the fleet costs to run; moving power costs nothing but time and losses
expression: sum(p * marginal_cost)
The model-building half of examples/ports/references/pypsa/pypsa_link_delay.py:
def build(tables: dict[str, pd.DataFrame]) -> pypsa.Network:
"""The port's tables as a PyPSA network, column for column.
``tables`` is the same mapping the specsolve call attaches as ``sources``.
Both links are given a ``delay`` — 2 for ``ship`` and 0 for ``wire`` — so the
column is read rather than a constant applied to everything, and neither
link is extendable: a delay is about *when* energy arrives, and a capacity
decision would give a mismatch a second thing to be about.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'])
n.add('Bus', tables['bus']['bus'])
generators: pd.DataFrame = tables['generator'].set_index('generator')
n.add(
'Generator',
generators.index,
bus=generators['gen_bus'],
p_nom=tables['p_nom'].set_index('generator')['value'],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
)
links: pd.DataFrame = tables['link'].set_index('link')
n.add(
'Link',
links.index,
bus0=links['link_from'],
bus1=links['link_to'],
p_nom=tables['link_p_nom'].set_index('link')['value'],
efficiency=tables['efficiency'].set_index('link')['value'],
delay=tables['delay'].set_index('link')['value'],
cyclic_delay=False,
)
load: pd.DataFrame = tables['load'].pivot(index='snapshot', columns='bus', values='value')
for bus in tables['bus']['bus']:
n.add('Load', f'load_{bus}', bus=bus, p_set=load[bus])
return n
cyclic_delay=False is edge=0, one for one. PyPSA's own attribute table
says of the non-cyclic case that energy is lost at the tail and first snapshots
receive nothing from delayed links. That is what edge=0 states: the vacated
positions contribute zero. The cyclic case is edge='wrap', which
cyclic storage ports on a different component, so
this model takes the non-cyclic one.
The language refuses a per-entity shift with no edge= at all:
LanguageError: constraint 'nodal_balance': shift(offset=delay) leaves the vacated
positions absent, which a per-entity offset cannot say yet.
Add edge='wrap' for a cyclic translation, or edge=<number> for what the vacated
positions contribute.
The refusal is right here. PyPSA zeroes those positions rather than leaving them absent, and the two readings build different models.
Both ends of the horizon show. Shipments of 44.44 leave in snapshots 0 and 1
and arrive as 40 in snapshots 2 and 3. Nothing is shipped in snapshots 4 and 5,
because it would arrive after the horizon ends and be lost. The prices say the
same thing: port_b pays 100 while it waits, then 11.11, the cheap unit's 10
divided by the ship's 0.9.
What it exercises¶
shift(x, along=dim, offset=p, edge=0) with p an integer column, inside a
grouped sum that lands on a different entity's row. The shift moves a quantity
between two places rather than along one.