PyPSA LOPF — two coordinates on one dimension¶
A meshed AC–DC network under a CO₂ budget. PyPSA's own ac-dc-meshed example.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 18441021.477729216, matched to
rtol=1e-09, nodal prices included.
Every model above puts a generator on a bus and stops there. Here a generator
also burns a carrier, and both maps do work. The nodal balance groups
generation through bus; the CO₂ budget reads an emission rate back down
through carrier. Two coordinates on one dimension, landing on two different
targets.
Nine buses, six generators, seven passive lines and four controllable links across three sub-networks: large enough that the two kinds of branch matter. Capacity is a decision everywhere, so the model prices what it builds as well as what it runs.
The model¶
The same model, as math
PyPSA linear optimal power flow on a meshed AC-DC network whose generators sit on a bus and burn a carrier, with capacity to build and a CO2 budget priced through the second map: the nodal balance groups through the bus coordinate, and the budget reads an emissions rate back down through the carrier. PyPSA's own ac-dc-meshed example. Optimum 18441021.477729216, 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{line\_from}: \mathcal{L} \to \mathcal{B},\ \mathrm{line\_to}: \mathcal{L} \to \mathcal{B},\ \mathrm{link\_from}: \mathcal{I} \to \mathcal{B},\ \mathrm{link\_to}: \mathcal{I} \to \mathcal{B}\) — network nodes |
| \(\mathcal{C}\) | index \(c\) — carrier with \(\mathrm{gen\_carrier}: \mathcal{E} \to \mathcal{C}\) — what a generator burns, and what its emissions are a property of |
| \(\mathcal{E}\) | index \(e\) — generator with \(\mathrm{gen\_bus}: \mathcal{E} \to \mathcal{B},\ \mathrm{gen\_carrier}: \mathcal{E} \to \mathcal{C}\) — generating units, each sitting on a bus and burning a carrier — two coordinates on one dimension, landing on two different axes |
| \(\mathcal{L}\) | index \(l\) — line with \(\mathrm{line\_from}: \mathcal{L} \to \mathcal{B},\ \mathrm{line\_to}: \mathcal{L} \to \mathcal{B}\) — passive AC lines, each joining two buses |
| \(\mathcal{I}\) | index \(i\) — link with \(\mathrm{link\_from}: \mathcal{I} \to \mathcal{B},\ \mathrm{link\_to}: \mathcal{I} \to \mathcal{B}\) — controllable connections, each joining two buses |
| \(\mathcal{Y}\) | index \(y\) — cycle — one independent loop per meshed sub-network |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{load}\) | load over \(\mathcal{T} \times \mathcal{B}\) — demand at each bus in each snapshot |
| \(\mathrm{p}^{\mathrm{max,pu}}\) | p_max_pu over \(\mathcal{T} \times \mathcal{E}\) — share of built capacity a generator can produce in a snapshot |
| \(\mathrm{p}^{\mathrm{nom,min}}\) | p_nom_min over \(\mathcal{E}\) — capacity a generator already has, and cannot fall below |
| \(\mathrm{marginal\_cost}\) | marginal_cost over \(\mathcal{E}\) — cost of one unit of output |
| \(\mathrm{gen\_capital\_cost}\) | gen_capital_cost over \(\mathcal{E}\) — annualised cost of a unit of generator capacity |
| \(\mathrm{efficiency}\) | efficiency over \(\mathcal{E}\) — share of the carrier's energy a generator turns into output |
| \(\mathrm{co2\_per\_mwh}\) | co2_per_mwh over \(\mathcal{C}\) — emissions per unit of carrier burned, a property of the carrier |
| \(\mathrm{line\_capital\_cost}\) | line_capital_cost over \(\mathcal{L}\) — annualised cost of a unit of line capacity |
| \(\mathrm{link\_capital\_cost}\) | link_capital_cost over \(\mathcal{I}\) — annualised cost of a unit of link capacity |
| \(\mathrm{link\_p\_max\_pu}\) | link_p_max_pu over \(\mathcal{I}\) — share of its capacity a link may carry forwards |
| \(\mathrm{link\_p\_min\_pu}\) | link_p_min_pu over \(\mathcal{I}\) — share of its capacity a link may carry backwards, negative by convention |
| \(\mathrm{cycle\_incidence}\) | cycle_incidence over \(\mathcal{Y} \times \mathcal{L}\) — the cycle basis, as a sparse table of impedance times direction. A line may belong to several cycles, so this cannot be a coordinate. |
| \(\mathrm{co2\_limit}\) | co2_limit (scalar) — emissions the whole horizon is allowed |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{E}\) — output of a generator in a snapshot |
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{E}\) — generator capacity to hold, built on top of what already stands |
| \(f\) | f over \(\mathcal{T} \times \mathcal{L}\) — flow on a line, signed towards its line_to bus — not chosen, but whatever the voltage law leaves |
| \(s^{\mathrm{nom}}\) | s_nom over \(\mathcal{L}\) — line capacity to build |
| \(g\) | g over \(\mathcal{T} \times \mathcal{I}\) — flow on a link, signed towards the bus it delivers at — chosen, which is what makes it a link and not a line |
| \(\mathit{link\_p\_nom}\) | link_p_nom over \(\mathcal{I}\) — link capacity to build |
Upright is what the data supplies — a parameter such as \(\mathrm{load}\), 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.
Objective¶
Subject to¶
within_capacity
line_upper
line_lower
link_upper
link_lower
nodal_balance
kirchhoff_voltage_law
co2_budget
Variable domains¶
p
p_nom
f
s_nom
g
link_p_nom
The tabs start from the instance's tables — one frame per parameter.
description: >-
PyPSA linear optimal power flow on a meshed AC-DC network whose
generators sit on a bus and burn a carrier, with capacity to build and a CO2
budget priced through the second map: the nodal balance groups through the
bus coordinate, and the budget reads an emissions rate back down through the
carrier. PyPSA's own ac-dc-meshed example.
Optimum 18441021.477729216, from PyPSA itself.
dimensions:
snapshot:
description: dispatch periods
dtype: int
bus:
description: network nodes
dtype: str
carrier:
description: what a generator burns, and what its emissions are a property of
dtype: str
generator:
description: >-
generating units, each sitting on a bus and burning a carrier — two
coordinates on one dimension, landing on two different axes
dtype: str
line:
description: passive AC lines, each joining two buses
dtype: str
link:
description: controllable connections, each joining two buses
dtype: str
cycle:
description: one independent loop per meshed sub-network
dtype: str
relations:
gen_bus:
description: the bus a generator sits on
key: generator
values: bus
gen_carrier:
description: the carrier a generator burns
key: generator
values: carrier
line_from:
description: the bus a line leaves
key: line
values: bus
line_to:
description: the bus a line arrives at
key: line
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:
load:
description: demand at each bus in each snapshot
dims: [snapshot, bus]
p_max_pu:
description: share of built capacity a generator can produce in a snapshot
dims: [snapshot, generator]
p_nom_min:
description: capacity a generator already has, and cannot fall below
dims: [generator]
marginal_cost:
description: cost of one unit of output
dims: [generator]
gen_capital_cost:
description: annualised cost of a unit of generator capacity
dims: [generator]
efficiency:
description: share of the carrier's energy a generator turns into output
dims: [generator]
co2_per_mwh:
description: emissions per unit of carrier burned, a property of the carrier
dims: [carrier]
line_capital_cost:
description: annualised cost of a unit of line capacity
dims: [line]
link_capital_cost:
description: annualised cost of a unit of link capacity
dims: [link]
link_p_max_pu:
description: share of its capacity a link may carry forwards
dims: [link]
link_p_min_pu:
description: share of its capacity a link may carry backwards, negative by convention
dims: [link]
cycle_incidence:
description: >-
the cycle basis, as a sparse table of impedance times direction. A line
may belong to several cycles, so this cannot be a coordinate.
dims: [cycle, line]
co2_limit:
description: emissions the whole horizon is allowed
dims: []
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
bounds:
lower: 0
p_nom:
description: generator capacity to hold, built on top of what already stands
dims: [generator]
bounds:
lower: p_nom_min
f:
description: >-
flow on a line, signed towards its `line_to` bus — not chosen, but whatever
the voltage law leaves
dims: [snapshot, line]
s_nom:
description: line capacity to build
dims: [line]
bounds:
lower: 0
g:
description: >-
flow on a link, signed towards the bus it delivers at — chosen, which is
what makes it a link and not a line
dims: [snapshot, link]
link_p_nom:
description: link capacity to build
dims: [link]
bounds:
lower: 0
constraints:
within_capacity:
description: a generator produces no more than the built capacity available to it
dims: [snapshot, generator]
expression: p <= p_nom * p_max_pu
line_upper:
dims: [snapshot, line]
expression: f <= s_nom
line_lower:
dims: [snapshot, line]
expression: f >= -s_nom
link_upper:
dims: [snapshot, link]
expression: g <= link_p_nom * link_p_max_pu
link_lower:
dims: [snapshot, link]
expression: g >= link_p_nom * link_p_min_pu
nodal_balance:
description: >-
what is generated at a bus plus what arrives over the lines and links
meets the load there
dims: [snapshot, bus]
expression: >-
sum(p, by=gen_bus, over=generator, into=bus)
+ sum(f, by=line_to, over=line, into=bus) - sum(f, by=line_from, over=line, into=bus)
+ sum(g, by=link_to, over=link, into=bus) - sum(g, by=link_from, over=link, into=bus)
== load
kirchhoff_voltage_law:
description: around each independent cycle the impedance-weighted flows sum to zero
dims: [snapshot, cycle]
expression: sum(f * cycle_incidence, over=line) == 0
co2_budget:
description: >-
PyPSA's primary-energy constraint — a generator's emissions are its
output divided by its efficiency, priced at its carrier's rate, and the
horizon's total stays inside the budget
dims: []
expression: >-
sum(sum(p * at(co2_per_mwh, by=gen_carrier, over=carrier, into=generator) / efficiency, over=generator), over=snapshot)
<= co2_limit
objective:
sense: minimize
description: what the fleet costs to run, plus what the generation and network capacity cost to build
expression: >-
sum(p * marginal_cost)
+ sum(p_nom * gen_capital_cost)
+ sum(s_nom * line_capital_cost)
+ sum(link_p_nom * link_capital_cost)
The model-building half of examples/ports/references/pypsa/pypsa_ac_dc.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``.
The port carries its cycle basis as ``cycle_incidence`` because computing
one is a graph algorithm and so data preparation; PyPSA derives its own
from ``line_x`` / ``line_r``, which is why both are in the instance. The
two must describe the same cycle space, and the objectives agreeing is
what says they do.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'].tolist())
carrier = _series(tables['bus_carrier'], 'bus')
for bus, ct in carrier.items():
n.add('Bus', bus, carrier=ct)
co2 = _series(tables['co2_per_mwh'], 'carrier')
for name, rate in co2.items():
n.add('Carrier', name, co2_emissions=rate)
generators = tables['generator'].set_index('generator')
p_max_pu = _wide(tables['p_max_pu'], 'generator')
for g, row in generators.iterrows():
n.add(
'Generator',
g,
bus=row['bus'],
carrier=row['carrier'],
p_nom_extendable=True,
p_nom=_series(tables['gen_p_nom_existing'], 'generator')[g],
p_nom_min=_series(tables['p_nom_min'], 'generator')[g],
marginal_cost=_series(tables['marginal_cost'], 'generator')[g],
capital_cost=_series(tables['gen_capital_cost'], 'generator')[g],
efficiency=_series(tables['efficiency'], 'generator')[g],
p_max_pu=p_max_pu[g],
)
for line, ends in tables['line'].set_index('line').iterrows():
bus0, bus1 = ends['line_from'], ends['line_to']
n.add(
'Line',
line,
bus0=bus0,
bus1=bus1,
x=_series(tables['line_x'], 'line')[line],
r=_series(tables['line_r'], 'line')[line],
s_nom=_series(tables['line_s_nom_existing'], 'line')[line],
s_nom_extendable=True,
capital_cost=_series(tables['line_capital_cost'], 'line')[line],
)
for link, ends in tables['link'].set_index('link').iterrows():
bus0, bus1 = ends['link_from'], ends['link_to']
n.add(
'Link',
link,
bus0=bus0,
bus1=bus1,
p_nom_extendable=True,
p_nom=_series(tables['link_p_nom_existing'], 'link')[link],
p_min_pu=_series(tables['link_p_min_pu'], 'link')[link],
p_max_pu=_series(tables['link_p_max_pu'], 'link')[link],
capital_cost=_series(tables['link_capital_cost'], 'link')[link],
)
load = _wide(tables['load'], 'bus')
for bus in load.columns:
if load[bus].any():
n.add('Load', bus, bus=bus, p_set=load[bus])
n.add(
'GlobalConstraint',
'co2_limit',
type='primary_energy',
carrier_attribute='co2_emissions',
sense='<=',
constant=float(tables['co2_limit']),
)
return n
An emission rate is a property of the carrier, and at() is how a generator
reads it. co2_per_mwh is dimensioned over carrier alone: six generators,
two rates. at(co2_per_mwh, by=gen_carrier, over=carrier, into=generator) walks the map backwards to put
the right rate beside each generator's output. PyPSA does the same join
through n.carriers.
The recorded optimum is the system cost, not n.objective. Every component
here is extendable, so PyPSA credits the capital already standing in p_nom.
It reports the change against that starting point, a negative number on this
network. The port has no starting point to credit and states the cost
outright, so the recorded figure is n.objective + n.objective_constant.
What it exercises¶
Two relations keyed over one dimension onto different dimensions, and at() reading a
parameter that lives only on the coarse end. Beside them, the shapes the
models above already established: sum(by=) on both ends of two different branch
dimensions, and a cycle basis as a sparse (cycle, line) parameter.
The cycle basis carries impedance rather than reactance alone. PyPSA applies
the voltage law with x inside an AC sub-network and r inside a DC one, and
this network has one meshed loop of each. Which value belongs in the row is
decided in data preparation, where
the limits
put graph work; the language sees one incidence table either way.
No new construct was needed.