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. AC-DC ports one of them, the CO₂ cap, which groups nothing: 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_placement, over=generator, into=carrier), over=snapshot) <= energy_cap |
per-(bus, carrier) capacity cap |
that carrier's capacity at one bus | sum(p_nom, by=gen_placement, over=generator, into=[bus, carrier]) <= 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 with \(\mathrm{gen\_placement}: \mathcal{E} \to \mathcal{B} \times \mathcal{C},\ \mathrm{link\_from}: \mathcal{L} \to \mathcal{B},\ \mathrm{link\_to}: \mathcal{L} \to \mathcal{B}\) — network nodes |
| \(\mathcal{C}\) | index \(c\) — carrier with \(\mathrm{gen\_placement}: \mathcal{E} \to \mathcal{B} \times \mathcal{C}\) — what a generator burns, and what a global limit selects on |
| \(\mathcal{E}\) | index \(e\) — generator with \(\mathrm{gen\_placement}: \mathcal{E} \to \mathcal{B} \times \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},\ \mathrm{link\_to}: \mathcal{L} \to \mathcal{B}\) — controllable connections, each joining two buses |
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{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{link\_capital\_cost}\) | link_capital_cost over \(\mathcal{L}\) — annualised cost of a unit of link capacity |
| \(\mathrm{link\_length}\) | link_length over \(\mathcal{L}\) — how far a link reaches — what turns built capacity into a volume |
| \(\mathrm{energy\_cap}\) | energy_cap over \(\mathcal{C}\) — energy a carrier may deliver over the whole horizon, for the carriers that have such a limit |
| \(\mathrm{bus\_capacity\_cap}\) | bus_capacity_cap over \(\mathcal{B} \times \mathcal{C}\) — capacity of one carrier a bus may hold, for the pairs that cap it — PyPSA writes the carrier into a column name (nom_max_wind), so the pair is the limit's own key |
| \(\mathrm{volume\_cap}\) | volume_cap (scalar) — capacity times length the whole network may build |
| \(\mathrm{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\_p\_nom}\) | link_p_nom over \(\mathcal{L}\) — capacity built on a link |
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
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
relations:
gen_placement:
description: the bus a generator sits on and the carrier it burns
key: generator
values: [bus, carrier]
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]
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]
bus_capacity_cap:
description: >-
capacity of one carrier a bus may hold, for the pairs that cap it — PyPSA
writes the carrier into a column name (`nom_max_wind`), so the pair is the
limit's own key
dims: [bus, carrier]
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
dims: [snapshot, generator]
bounds:
lower: 0
p_nom:
description: capacity built at a generator
dims: [generator]
bounds:
lower: 0
g:
description: flow on a link, towards the bus it delivers at
dims: [snapshot, link]
bounds:
lower: 0
link_p_nom:
description: capacity built on a link
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
within_link_capacity:
dims: [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
dims: [snapshot, bus]
expression: >-
sum(p, by=gen_placement, over=generator, into=bus)
+ sum(g, by=link_to, over=link, into=bus) - sum(g, by=link_from, over=link, into=bus)
== 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
dims: [carrier]
where: energy_cap
expression: sum(sum(p, by=gen_placement, over=generator, into=carrier), over=snapshot) <= energy_cap
carrier_capacity_at_bus:
description: >-
a bus that caps a carrier holds no more of it than that — one grouping
lands the built capacity on the (bus, carrier) pair the limit is keyed by,
so no selector column and no mask stand between the two
dims: [bus, carrier]
where: bus_capacity_cap
expression: sum(p_nom, by=gen_placement, over=generator, into=[bus, carrier]) <= 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
dims: []
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
dims: []
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: >-
sum(p * marginal_cost)
+ sum(p_nom * gen_capital_cost)
+ sum(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 specsolve call attaches 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 cap into a column name,
nom_max_wind. That column name is a (bus, carrier) pair, and the port says
so. One grouping through both maps lands the built capacity on that pair, and
bus_capacity_cap is a table keyed by it.
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. The language can say it, but 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 expected to
arrive; #966 tracks reporting
it upstream. Multi-period investment proves the limit
itself, where a period exists to name.
What it exercises¶
A reduction over two dimensions at once (sum(sum(p, by=gen_placement, over=generator, into=carrier), over=snapshot)),
one grouping landing on a pair of dimensions (sum(p_nom, by=gen_placement, over=generator, into=[bus, carrier])),
and two scalar-bounded sums over one set with different weights. No construct
here is new, for five constraints PyPSA implements in five functions.