PyPSA multi-link¶
One Link, one input bus, several output buses, each output derated by its
own efficiency — PyPSA's spelling for a CHP plant, an electrolyser with waste
heat, any conversion with more than one product.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 1100, matched to
rtol=1e-09.
Beside the ladder, because the feature is the schema. Every rung varies
what a model says; a multi-link varies what a table is. PyPSA holds the
relation wide — bus0, bus1, bus2, efficiency, efficiency2, an empty
bus2 where a link has only two ends — so every arity the data reaches adds a
column pair to the component itself. Here the relation is one incidence
parameter over (link, bus): -1 at the input, +efficiency at each
output, rows absent elsewhere. Arity is the number of rows that name the link,
so the three-ended CHP and the two-ended boiler sit in the same three columns,
and a four-ended link would change nothing but the data.
One decision per link survives the tidying: p, what the link draws at its
input — PyPSA's p0. The balance is the contraction sum(incidence * p,
over=link), which lands the draw on every bus the link's rows name — the same
sum a linopy user writes as (incidence * p).sum('link') against a dense
array, and one melt away from PyPSA's own wide CSV.
The instance is a toy gas-to-energy system: a CHP (gas → 0.4 elec + 0.4 heat,
capacity 50), a boiler (gas → 0.8 heat), an OCGT (gas → 0.5 elec), gas at 10.
The marginal heat unit comes from the boiler and the marginal electric unit
from the OCGT, so the prices are 10/0.8 = 12.5 and 10/0.5 = 20 — and one
unit of gas through the CHP earns 0.4·20 + 0.4·12.5 = 13 against 10, so the
CHP runs at its cap of 50 and the others top up: flows (50, 20, 40), gas
110, objective 1100.
The model¶
The same model, as math
PyPSA multi-link: one Link, one input bus, several output buses, each output derated by its own efficiency. PyPSA spells the relation wide — bus0, bus1, bus2, efficiency, efficiency2, an empty bus2 where a link has no third terminal — and grows a column pair per arity. Here the relation is one incidence parameter over link and bus, so arity is the number of rows that name the link. Optimum 1100.0, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{B}\) | index \(b\) --- bus --- network nodes |
| \(\mathcal{G}\) | index \(g\) --- generator with \(\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B}\) --- generating units, each sitting on one bus |
| \(\mathcal{L}\) | index \(l\) --- link --- conversions, each drawing at one bus and delivering at several |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathit{gen}^{\mathrm{p,nom}}\) | gen_p_nom over \(\mathcal{G}\) --- installed capacity of a generator |
| \(\mathit{marginal\_cost}\) | marginal_cost over \(\mathcal{G}\) --- cost of one unit of output |
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{L}\) --- the link's own capacity — a cap on what it draws at its input, p0 in PyPSA |
| \(\mathit{incidence}\) | incidence over \(\mathcal{L} \times \mathcal{B}\) --- each bus's share of the link's draw — minus one at the input and plus the efficiency at each output, with rows absent elsewhere; PyPSA's efficiency columns and the input's fixed minus one, tidied into rows |
| \(\mathit{load}\) | load over \(\mathcal{B}\) --- demand at each bus |
Variables¶
| Symbol | Meaning |
|---|---|
| \(\mathit{gen}\) | gen over \(\mathcal{G}\) --- output of a generator |
| \(p\) | p over \(\mathcal{L}\) --- the one decision per link, PyPSA's p — what it draws at its input. Every other end's flow is that draw scaled by its incidence entry, so it needs no variable of its own. |
Objective¶
Subject to¶
nodal_balance
Variable domains¶
gen
p
The tabs start from the instance’s tables — one frame per parameter.
description: >-
PyPSA multi-link: one Link, one input bus, several output buses, each output
derated by its own efficiency. PyPSA spells the relation wide — bus0, bus1,
bus2, efficiency, efficiency2, an empty bus2 where a link has no third
terminal — and grows a column pair per arity. Here the relation is one
incidence parameter over link and bus, so arity is the number of rows that
name the link. Optimum 1100.0, from PyPSA itself.
dimensions:
bus:
description: network nodes
dtype: str
generator:
description: generating units, each sitting on one bus
dtype: str
link:
description: conversions, each drawing at one bus and delivering at several
dtype: str
lookups:
gen_bus: {over: generator, into: bus, description: "the bus a generator sits on"}
parameters:
gen_p_nom:
description: installed capacity of a generator
dims: [generator]
marginal_cost:
description: cost of one unit of output
dims: [generator]
p_nom:
description: the link's own capacity — a cap on what it draws at its input, p0 in PyPSA
dims: [link]
incidence:
description: >-
each bus's share of the link's draw — minus one at the input and plus the
efficiency at each output, with rows absent elsewhere; PyPSA's efficiency
columns and the input's fixed minus one, tidied into rows
dims: [link, bus]
load:
description: demand at each bus
dims: [bus]
variables:
gen:
description: output of a generator
foreach: [generator]
bounds:
lower: 0
upper: gen_p_nom
p:
description: >-
the one decision per link, PyPSA's p — what it draws at its input. Every
other end's flow is that draw scaled by its incidence entry, so it needs
no variable of its own.
foreach: [link]
bounds:
lower: 0
upper: p_nom
constraints:
nodal_balance:
description: >-
what is generated at a bus plus what the links deliver there meets the
load. The contraction lands the draw on every bus its link's incidence
rows name — three ends or two, the expression never says.
foreach: [bus]
expression: >-
sum(gen, by=gen_bus)
+ sum(incidence * p, over=link)
== load
objective:
sense: minimize
description: total cost of generation; the conversions themselves are free here
expression: gen * marginal_cost
The model-building half of examples/ports/references/pypsa/pypsa_multilink.py:
def build(tables: dict[str, pd.DataFrame]) -> pypsa.Network:
"""The port's tables as a PyPSA network.
``tables`` is the same mapping the lpspec call binds as ``sources``; only
the incidence table changes shape on the way in, pivoted from one row per
link end into PyPSA's one row per link. The input end is the one with the
negative value — PyPSA fixes its share at -1, so the pivot asserts it: a
different input share is sayable in rows and not in these columns. Each
output end becomes the link's next port, its value the port's efficiency,
in the incidence table's row order. A link narrower than the instance's
widest is padded with ``''`` — PyPSA's spelling for a port a link does not
have — and a filler efficiency of 1.0 that no equation reads.
"""
incidence = tables['incidence']
inputs = incidence[incidence['value'] < 0].set_index('link')
assert (inputs['value'] == -1.0).all(), 'PyPSA fixes the input share of a Link at -1; this instance must too'
outputs = incidence[incidence['value'] > 0].copy()
outputs['port'] = outputs.groupby('link', sort=False).cumcount() + 1
buses = outputs.pivot(index='link', columns='port', values='bus')
efficiencies = outputs.pivot(index='link', columns='port', values='value')
links = pd.DataFrame(index=pd.Index(tables['link']['link'], name='link'))
links['bus0'] = inputs['bus']
for port in buses.columns:
links[f'bus{port}'] = buses[port].reindex(links.index).fillna('')
links['efficiency' if port == 1 else f'efficiency{port}'] = efficiencies[port].reindex(links.index).fillna(1.0)
n = pypsa.Network()
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['gen_p_nom'].set_index('generator')['value'],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
)
n.add('Link', links.index, p_nom=tables['p_nom'].set_index('link')['value'], **dict(links.items()))
load: pd.Series = tables['load'].set_index('bus')['value']
for bus in tables['bus']['bus']:
n.add('Load', f'load_{bus}', bus=bus, p_set=load[bus])
return n
The pivot is the argument. The PyPSA tab spends its first half turning
rows into columns — finding the input, numbering the outputs, padding the
narrow links with '' and a filler efficiency no equation reads — before a
single component exists. That reshape is not this port being awkward: it is
what the wide schema demands of any tidy source. The lpspec tab binds the
incidence table as it stands. And PyPSA fixes the input's share at -1, so
the pivot asserts it; in rows that constant is just data — an input entry of
-1.05 would model a link burning 5% of its draw in station load, with no new
column and no new construct.
When a link end needs a name of its own, the incidence entry stops being
enough. A per-end variable or bound (a heat-offtake cap on the CHP's heat
port alone), a value pulled through an end's bus with at(..., by=…), or a
link touching the same bus twice all need the ends reified as a dimension with
leg lookups — a parameter holds one value per (link, bus) pair and gives the
pair no identity. That is the other many-to-many idiom, and
reserves proves it with its three-legged offers; until an end
needs an identity, the incidence table is the readable form.