PyPSA global limits — four bounds, one shape¶
Limits that hold over a whole set at once: an energy total, a capacity at one bus, and the built network measured twice.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 127211.66666666666, matched to
rtol=1e-09.
Every other bound in this corpus belongs to a component. A global limit belongs to a set PyPSA selects by an attribute: all generators burning gas, all wind capacity at one bus, every extendable link. Rung 6 ports one of them — the CO₂ cap — but that one groups nothing, being a single bound over everything.
Four limits here, and each is the same sentence in the language: a sum over a group, against a bound only some rows carry.
| PyPSA | what it selects | the port's row |
|---|---|---|
operational_limit |
generators burning a carrier | sum(sum(p, by=gen_carrier), over=snapshot) <= energy_cap |
per-(bus, carrier) capacity cap |
that carrier's capacity at one bus | sum(p_nom * at(capped_carrier, by=gen_carrier), by=gen_bus) <= bus_capacity_cap |
transmission_volume_expansion_limit |
extendable links, weighted by length | sum(link_p_nom * link_length, over=link) <= volume_cap |
transmission_expansion_cost_limit |
the same links, weighted by money | sum(link_p_nom * link_capital_cost, over=link) <= expansion_cost_cap |
The model¶
The same model, as math
PyPSA's global constraints: four limits over four different selected sets — the energy a carrier may deliver, the capacity a carrier may hold at one bus, and the built transmission measured twice, once as length and once as money. Each is one grouped sum against a bound only some rows carry. Optimum 127211.66666666666, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) --- snapshot --- dispatch periods |
| \(\mathcal{B}\) | index \(b\) --- bus --- network nodes |
| \(\mathcal{C}\) | index \(c\) --- carrier --- what a generator burns, and what a global limit selects on |
| \(\mathcal{E}\) | index \(e\) --- generator with \(\mathrm{gen\_bus}: \mathcal{E} \to \mathcal{B},\enspace \mathrm{gen\_carrier}: \mathcal{E} \to \mathcal{C}\) --- generating units, each sitting on a bus and burning a carrier |
| \(\mathcal{L}\) | index \(l\) --- link with \(\mathrm{link\_from}: \mathcal{L} \to \mathcal{B},\enspace \mathrm{link\_to}: \mathcal{L} \to \mathcal{B}\) --- controllable connections, each joining two buses |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathit{load}\) | load over \(\mathcal{T} \times \mathcal{B}\) --- demand at each bus in each snapshot |
| \(p^{\mathrm{max,pu}}\) | p_max_pu over \(\mathcal{T} \times \mathcal{E}\) --- share of built capacity a generator can produce in a snapshot |
| \(\mathit{marginal\_cost}\) | marginal_cost over \(\mathcal{E}\) --- cost of one unit of output |
| \(\mathit{gen\_capital\_cost}\) | gen_capital_cost over \(\mathcal{E}\) --- annualised cost of a unit of generator capacity |
| \(\mathit{link}^{\mathrm{capital,cost}}\) | link_capital_cost over \(\mathcal{L}\) --- annualised cost of a unit of link capacity |
| \(\mathit{link}^{\mathrm{length}}\) | link_length over \(\mathcal{L}\) --- how far a link reaches — what turns built capacity into a volume |
| \(\mathit{energy\_cap}\) | energy_cap over \(\mathcal{C}\) --- energy a carrier may deliver over the whole horizon, for the carriers that have such a limit |
| \(\mathit{capped\_carrier}\) | capped_carrier over \(\mathcal{C}\) --- 1 for the carrier the per-bus capacity caps are about, 0 for the rest — the selection PyPSA writes into a column name, written here as data |
| \(\mathit{bus}^{\mathrm{capacity,cap}}\) | bus_capacity_cap over \(\mathcal{B}\) --- capacity of that carrier a bus may hold, for the buses that cap it |
| \(\mathit{volume\_cap}\) | volume_cap (scalar) --- capacity times length the whole network may build |
| \(\mathit{expansion\_cost\_cap}\) | expansion_cost_cap (scalar) --- money the whole network may spend building links |
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}\) --- capacity built at a generator |
| \(g\) | g over \(\mathcal{T} \times \mathcal{L}\) --- flow on a link, towards the bus it delivers at |
| \(\mathit{link}^{\mathrm{p,nom}}\) | link_p_nom over \(\mathcal{L}\) --- capacity built on a link |
Objective¶
Subject to¶
within_capacity
within_link_capacity
nodal_balance
carrier_energy
carrier_capacity_at_bus
transmission_volume
transmission_cost
Variable domains¶
p
p_nom
g
link_p_nom
The tabs start from the instance’s tables — one frame per parameter.
description: >-
PyPSA's global constraints: four limits over four different selected sets —
the energy a carrier may deliver, the capacity a carrier may hold at one bus,
and the built transmission measured twice, once as length and once as money.
Each is one grouped sum against a bound only some rows carry.
Optimum 127211.66666666666, from PyPSA itself.
dimensions:
snapshot:
description: dispatch periods
dtype: int
bus:
description: network nodes
dtype: str
carrier:
description: what a generator burns, and what a global limit selects on
dtype: str
generator:
description: generating units, each sitting on a bus and burning a carrier
dtype: str
link:
description: controllable connections, each joining two buses
dtype: str
lookups:
gen_bus:
description: the bus a generator sits on
over: generator
into: bus
gen_carrier:
description: the carrier a generator burns
over: generator
into: carrier
link_from:
description: the bus a link leaves
over: link
into: bus
link_to:
description: the bus a link arrives at
over: link
into: 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]
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]
link_capital_cost:
description: annualised cost of a unit of link capacity
dims: [link]
link_length:
description: how far a link reaches — what turns built capacity into a volume
dims: [link]
energy_cap:
description: >-
energy a carrier may deliver over the whole horizon, for the carriers that
have such a limit
dims: [carrier]
capped_carrier:
description: >-
1 for the carrier the per-bus capacity caps are about, 0 for the rest —
the selection PyPSA writes into a column name, written here as data
dims: [carrier]
bus_capacity_cap:
description: >-
capacity of that carrier a bus may hold, for the buses that cap it
dims: [bus]
volume_cap:
description: capacity times length the whole network may build
dims: []
expansion_cost_cap:
description: money the whole network may spend building links
dims: []
variables:
p:
description: output of a generator in a snapshot
foreach: [snapshot, generator]
bounds:
lower: 0
p_nom:
description: capacity built at a generator
foreach: [generator]
bounds:
lower: 0
g:
description: flow on a link, towards the bus it delivers at
foreach: [snapshot, link]
bounds:
lower: 0
link_p_nom:
description: capacity built on a link
foreach: [link]
bounds:
lower: 0
constraints:
within_capacity:
description: a generator produces no more than the built capacity available to it
foreach: [snapshot, generator]
expression: p <= p_nom * p_max_pu
within_link_capacity:
foreach: [snapshot, link]
expression: g <= link_p_nom
nodal_balance:
description: what is generated at a bus plus what arrives over the links meets the load there
foreach: [snapshot, bus]
expression: >-
sum(p, by=gen_bus)
+ sum(g, by=link_to) - sum(g, by=link_from)
== load
carrier_energy:
description: >-
a carrier with an energy limit delivers no more than it over the horizon —
the generators are grouped onto the carrier they burn, which is the
selection PyPSA makes by querying its own table
foreach: [carrier]
where: energy_cap
expression: sum(sum(p, by=gen_carrier), over=snapshot) <= energy_cap
carrier_capacity_at_bus:
description: >-
a bus that caps the capped carrier holds no more of it than that — the
capacity is grouped onto the bus and masked to the one carrier, because a
sum cannot yet group through two lookups at once
foreach: [bus]
where: bus_capacity_cap
expression: sum(p_nom * at(capped_carrier, by=gen_carrier), by=gen_bus) <= bus_capacity_cap
transmission_volume:
description: >-
capacity times length, summed over the links — a limit on how much network
is built, in the unit a planner is granted
foreach: []
expression: sum(link_p_nom * link_length, over=link) <= volume_cap
transmission_cost:
description: >-
the same set weighted by money instead of distance, which is why the two
limits are not each other: the long link is the cheap one
foreach: []
expression: sum(link_p_nom * link_capital_cost, over=link) <= expansion_cost_cap
objective:
sense: minimize
description: what the fleet costs to run, plus what the generation and link capacity cost to build
expression: >-
p * marginal_cost
+ p_nom * gen_capital_cost
+ link_p_nom * link_capital_cost
The model-building half of examples/ports/references/pypsa/pypsa_global_limits.py:
def build(
tables: dict[str, pd.DataFrame],
limits: dict[str, dict[str, object]] | None = None,
bus_capacity_cap: bool = True,
) -> pypsa.Network:
"""The port's tables as a PyPSA network, column for column.
``tables`` is the same mapping the lpspec call binds as ``sources``.
``limits`` defaults to all three global-constraint rows and
``bus_capacity_cap`` to on; dropping one is how ``main`` measures what it is
worth. Every generator and every link is extendable, because a limit on
capacity has nothing to bind on a component whose capacity is data.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'])
n.add('Bus', tables['bus']['bus'])
n.add('Carrier', tables['carrier']['carrier'])
n.add('Carrier', 'DC')
generators: pd.DataFrame = tables['generator'].set_index('generator')
p_max_pu: pd.DataFrame = tables['p_max_pu'].pivot(index='snapshot', columns='generator', values='value')
n.add(
'Generator',
generators.index,
bus=generators['gen_bus'],
carrier=generators['gen_carrier'],
p_nom_extendable=True,
p_max_pu=p_max_pu[generators.index],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
capital_cost=tables['gen_capital_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'],
carrier='DC',
p_nom_extendable=True,
length=tables['link_length'].set_index('link')['value'],
capital_cost=tables['link_capital_cost'].set_index('link')['value'],
)
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])
for name, attributes in (LIMITS if limits is None else limits).items():
n.add('GlobalConstraint', name, **attributes)
if bus_capacity_cap:
bus, column, cap = BUS_CAPACITY_CAP
n.buses.loc[bus, column] = cap
return n
All four bind, and each is worth something. Dropping one at a time, on the
same instance: the gas energy cap is worth 7411.67, the volume limit
541.67, the cost limit 176.67, and the cap on wind at east 283.89.
A global limit that does not bind proves nothing about the language, so the
reference drops each in turn and prints the four numbers.
The two link limits are not each other. north_south is 100 km at 200/MW,
east_south 50 km at 400/MW, so length and money rank the two links in
opposite orders. Both limits are tight at the optimum, which fixes the build
exactly: 27.83 MW and 12.33 MW solve 100a + 50b = 3400 and
200a + 400b = 10500 together.
The selection is data, not a construct. PyPSA selects by querying its own
tables — carrier == "gas" — and writes the per-bus one into a column name,
nom_max_wind. The port groups through the gen_carrier lookup for the first
and masks with capped_carrier for the second. The mask is there because a sum
cannot group through two lookups at once yet
(#704); with that, the second
row becomes sum(p_nom, by=[gen_carrier, gen_bus]) <= cap over a
(bus, carrier) table and the mask disappears.
The fifth limit, which PyPSA does not build¶
tech_capacity_expansion_limit — a carrier's capacity across the whole network
— is missing from the table above, and not because the language cannot say it.
In pypsa 1.2.4 a single-period network cannot get one built at all:
no investment_period emits no constraint at all
investment_period=0 raises ValueError: Investment period not in `n.investment_periods`
global_constraints.py:48 groups the rows by
["carrier_attribute", "sense", "investment_period"]; where no period is given
that key is NaN, pandas drops NaN keys, and the row leaves no constraint
behind. The next line reads period = None if isnan(period) else int(period),
so NaN is plainly expected to arrive — which makes this theirs to fix rather
than ours to work around, and
#966 tracks reporting it. The
limit itself will be proved by the multi-period rung, where a period exists to
name.
What it exercises¶
A reduction over two dimensions at once (sum(sum(p, by=gen_carrier), over=snapshot)),
a masked capacity sum grouped onto a second axis, and two scalar-bounded sums
over one set with different weights. No construct here is new — which is the
result, for five constraints PyPSA implements in five functions.