transport¶
A network: generators sit on buses, lines connect buses, and power balances at every bus.
✔ Agrees with hand-written linopy 0.9.0 — objective 4400, matched to
rtol=1e-09.
The problem¶
Each sum is over the lines or generators a coordinate map sends to bus \(b\) — \(\mathrm{bus}\), \(\mathrm{to}\) and \(\mathrm{from}\) are the coordinates the dimensions declare, not sets in their own right. Load is \(d\) here, because \(\ell\) is already the line index.
The model¶
The same model, as math
Least-cost dispatch over a network, where a generator sits on a bus, a line joins two of them, and what is generated has to reach the load over the lines.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{S}\) | index \(s\) --- snapshot --- dispatch periods |
| \(\mathcal{G}\) | index \(g\) --- generator with \(\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B}\) --- generating units, each sitting on one bus |
| \(\mathcal{B}\) | index \(b\) --- bus --- network nodes |
| \(\mathcal{L}\) | index \(\ell\) --- line with \(\mathrm{line\_from}: \mathcal{L} \to \mathcal{B},\enspace \mathrm{line\_to}: \mathcal{L} \to \mathcal{B}\) --- transmission lines, each joining two buses |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\bar p\) | p_max over \(\mathcal{G}\) --- installed capacity |
| \(c\) | cost over \(\mathcal{G}\) --- marginal cost |
| \(\bar f\) | cap over \(\mathcal{L}\) --- forward transmission limit |
| \(\underline{f}\) | neg_cap over \(\mathcal{L}\) --- reverse transmission limit |
| \(d\) | load over \(\mathcal{S} \times \mathcal{B}\) --- demand at each bus |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{S} \times \mathcal{G}\) --- output of a generator in a snapshot |
| \(f\) | f over \(\mathcal{S} \times \mathcal{L}\) --- flow on a line, signed towards its line_to bus |
Objective¶
Subject to¶
balance
Variable domains¶
p
f
The tabs start from the instance’s tables — one frame per parameter.
description: >-
Least-cost dispatch over a network, where a generator sits on a bus, a line
joins two of them, and what is generated has to reach the load over the
lines.
dimensions:
snapshot:
description: dispatch periods
dtype: int
generator:
description: generating units, each sitting on one bus
dtype: str
bus:
description: network nodes
dtype: str
line:
description: transmission lines, each joining two buses
dtype: str
lookups:
gen_bus:
description: the bus a generator sits on
over: generator
into: bus
line_from:
description: the bus a line leaves
over: line
into: bus
line_to:
description: the bus a line arrives at
over: line
into: bus
parameters:
p_max:
description: installed capacity
dims: [generator]
cost:
description: marginal cost
dims: [generator]
cap:
description: forward transmission limit
dims: [line]
neg_cap:
description: reverse transmission limit
dims: [line]
load:
description: demand at each bus
dims: [snapshot, bus]
variables:
p:
description: output of a generator in a snapshot
foreach: [snapshot, generator]
bounds:
lower: 0
upper: p_max
f:
description: flow on a line, signed towards its `line_to` bus
foreach: [snapshot, line]
bounds:
lower: neg_cap
upper: cap
expressions:
gen_at_bus:
expression: sum(p, by=gen_bus)
description: what the generators sitting on a bus produce there
net_inflow:
expression: sum(f, by=line_to) - sum(f, by=line_from)
description: flow arriving at a bus minus flow leaving it, so a negative value is a net export
constraints:
balance:
description: what is generated at a bus plus what arrives over the lines meets the load there
foreach: [snapshot, bus]
expression: gen_at_bus + net_inflow == load
objective:
sense: minimize
description: total cost of generation; moving power over a line is free here
expression: p * cost
The model-building half of examples/ports/references/linopy/transport.py:
def build(tables: dict[str, pd.DataFrame]) -> linopy.Model:
"""The instance's tables as a linopy model, row for row.
``tables`` is the same mapping the lpspec call binds as ``sources``.
"""
p_max: pd.Series = tables['p_max'].set_index('generator')['value']
cost: pd.Series = tables['cost'].set_index('generator')['value']
cap: pd.Series = tables['cap'].set_index('line')['value']
neg_cap: pd.Series = tables['neg_cap'].set_index('line')['value']
load = xr.DataArray(tables['load'].pivot(index='snapshot', columns='bus', values='value'))
snapshots, buses = load.indexes['snapshot'], load.indexes['bus']
gen_at = pd.DataFrame(0.0, index=buses, columns=p_max.index)
for gen, bus in zip(tables['generator']['generator'], tables['generator']['gen_bus'], strict=True):
gen_at.loc[bus, gen] = 1.0
flow_in = pd.DataFrame(0.0, index=buses, columns=cap.index)
for line, src, dst in zip(
tables['line']['line'], tables['line']['line_from'], tables['line']['line_to'], strict=True
):
flow_in.loc[dst, line] += 1.0
flow_in.loc[src, line] -= 1.0
m = linopy.Model()
p = m.add_variables(lower=0, upper=p_max, coords=[snapshots, p_max.index], name='p')
f = m.add_variables(lower=neg_cap, upper=cap, coords=[snapshots, cap.index], name='f')
m.add_constraints(
(p * xr.DataArray(gen_at)).sum('generator') + (f * xr.DataArray(flow_in)).sum('line') == load,
name='balance',
)
m.add_objective((p * cost).sum())
return m
What it exercises¶
Three sum(by=) calls, and they are what a network is in this language.
A model can declare lookups — gen_bus maps generator onto bus, line_from
and line_to map line — and sum(f, by=line_to) sums along a
line's line_to lookup, landing the result on bus. The same f is summed
twice through two different lookups, once as an inflow and once as an
outflow.
No adjacency matrix, and no join written by the modeller: the topology is data on the dimension.
The two halves of the balance are named expressions — substituted into the
constraint before either backend sees the model, so naming them costs nothing
at build or solve; what it buys is a constraint that reads as the sentence it
is, and a quantity the solution can hand back: expression('net_inflow') is
the bus-by-bus net flow the balance constrained, one definition for the
constraint and the report.
examples/transport.yaml · back to all models