PyPSA stochastic optimisation — capacity once, dispatch per future¶
Three futures over one network: the fleet is built before anyone knows which arrives, and dispatched after.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 33940.0, matched to
rtol=1e-09.
n.set_scenarios makes a network stochastic, and what it does is add one
dimension to some variables and not others:
That is the whole two-stage program. Capacity is a first-stage decision — one
number per generator, spanning no scenario, because it is taken while all three
futures are still open. Dispatch is second stage, one per future, chosen once
the load is known. define_objective then runs every term through _expected,
which selects each scenario and multiplies it by that scenario's weight
(optimize.py:361).
The three futures differ in one thing, the load:
| probability | snapshot 0 | 1 | 2 | |
|---|---|---|---|---|
mild |
0.6 | 100 | 120 | 90 |
cold |
0.3 | 130 | 160 | 110 |
severe |
0.1 | 170 | 210 | 140 |
base costs 150 to build and 10 to run, peak 120 and 70 — so the mix, not just
the total, is what the expectation decides.
The model¶
The same model, as math
PyPSA stochastic optimisation: one network and three futures, where capacity is chosen once and dispatch is chosen after the load is known. The two stages differ only in which dimension they span — the capacity variable has no scenario, the dispatch variable does — and the objective is the probability-weighted expectation over them. Optimum 33940.0, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{S}\) | index \(s\) --- scenario --- the futures the fleet is built against, one of which will happen |
| \(\mathcal{T}\) | index \(t\) --- snapshot --- dispatch periods, the same in every future |
| \(\mathcal{G}\) | index \(g\) --- generator --- generating units, each built once and run in every future |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathit{probability}\) | probability over \(\mathcal{S}\) --- how likely a future is — the weights the expectation is taken with |
| \(\mathit{load}\) | load over \(\mathcal{S} \times \mathcal{T}\) --- demand to be met, and the one thing that differs between futures |
| \(\mathit{capex}\) | capex over \(\mathcal{G}\) --- cost of holding one unit of capacity over the horizon |
| \(\mathit{opex}\) | opex over \(\mathcal{G}\) --- cost of one unit of output |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{G}\) --- capacity built at a generator — the first-stage decision, which spans no scenario because it is taken before anyone knows which future arrived |
| \(p\) | p over \(\mathcal{S} \times \mathcal{T} \times \mathcal{G}\) --- output of a generator in a snapshot of a future — the second-stage decision, one per scenario |
Objective¶
Subject to¶
within_capacity
power_balance
Variable domains¶
p_nom
p
The tabs start from the instance’s tables — one frame per parameter.
description: >-
PyPSA stochastic optimisation: one network and three futures, where capacity
is chosen once and dispatch is chosen after the load is known. The two stages
differ only in which dimension they span — the capacity variable has no
scenario, the dispatch variable does — and the objective is the
probability-weighted expectation over them.
Optimum 33940.0, from PyPSA itself.
dimensions:
scenario:
description: the futures the fleet is built against, one of which will happen
dtype: str
snapshot:
description: dispatch periods, the same in every future
dtype: int
generator:
description: generating units, each built once and run in every future
dtype: str
parameters:
probability:
description: how likely a future is — the weights the expectation is taken with
dims: [scenario]
load:
description: demand to be met, and the one thing that differs between futures
dims: [scenario, snapshot]
capex:
description: cost of holding one unit of capacity over the horizon
dims: [generator]
opex:
description: cost of one unit of output
dims: [generator]
variables:
p_nom:
description: >-
capacity built at a generator — the first-stage decision, which spans no
scenario because it is taken before anyone knows which future arrived
foreach: [generator]
bounds:
lower: 0
p:
description: >-
output of a generator in a snapshot of a future — the second-stage
decision, one per scenario
foreach: [scenario, snapshot, generator]
bounds:
lower: 0
constraints:
within_capacity:
description: >-
a generator produces no more than the capacity built for it, in every
snapshot of every future — one capacity spanning three scenarios of rows
foreach: [scenario, snapshot, generator]
expression: p <= p_nom
power_balance:
description: what runs in this snapshot of this future meets the load there
foreach: [scenario, snapshot]
expression: sum(p, over=generator) == load
objective:
sense: minimize
description: >-
what the fleet costs to build, plus what it is expected to cost to run —
operating cost weighted by how likely the future it is incurred in is, and
capital cost paid once whichever future arrives
expression: p * opex * probability + p_nom * capex
The model-building half of examples/ports/references/pypsa/pypsa_stochastic.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 lpspec call binds as ``sources``.
The port's ``probability`` table is PyPSA's ``scenario_weightings``, passed
to ``set_scenarios``; the port's ``load`` over ``(scenario, snapshot)`` is a
``p_set`` frame whose columns carry the scenario level PyPSA gives every
time-varying input once the network is stochastic. Both generators are
extendable with no ``p_nom_max``: the fleet is what the model chooses, and a
ceiling nothing reaches would be a parameter the port carries for nothing.
"""
n = pypsa.Network()
n.set_snapshots(list(tables['snapshot']['snapshot']))
n.set_scenarios(tables['probability'].set_index('scenario')['value'])
n.add('Bus', 'hub')
generators: pd.DataFrame = tables['generator'].set_index('generator')
n.add(
'Generator',
generators.index,
bus='hub',
p_nom_extendable=True,
capital_cost=tables['capex'].set_index('generator')['value'],
marginal_cost=tables['opex'].set_index('generator')['value'],
)
n.add('Load', 'l', bus='hub')
load: pd.DataFrame = tables['load'].pivot(index='snapshot', columns='scenario', values='value')
n.loads_t.p_set = pd.DataFrame(
{(s, 'l'): load[s].to_numpy() for s in tables['probability']['scenario']}, index=n.snapshots
)
return n
The expectation is doing work, and here is what it is worth. Collapse the
three futures into their probability-weighted mean load — 116, 141, 101 — and the
same network builds 141 MW of base and no peak at all, for 24730.0. That
fleet has no dispatch in the severe future, which asks for 210: the
expected-value model does not merely cost less, it answers a question nobody
posed. what_the_mean_would_build() in the reference prints it.
One capacity, three scenarios of rows. within_capacity is p <= p_nom with
a foreach of [scenario, snapshot, generator] against a variable declared over
[generator] — nine rows reading one column, which is how a first-stage decision
is coupled to a second-stage one. Nothing in the language had to learn about
stages: the dim algebra broadcasts the capacity because the constraint's frame
says to.
Prices carry the probability, and they add up to the capital cost. The nodal
price in mild is 6.0 rather than 10.0, because the term it prices is weighted
by 0.6 — a PyPSA user reading marginal_price off a stochastic network is
reading a weighted price, and both implementations agree on that. The scarcity
rents are where the two stages meet:
| snapshot 0 | 1 | 2 | |
|---|---|---|---|
mild |
6 | 6 | 6 |
cold |
3 | 21 | 3 |
severe |
7 | 127 | 1 |
base is at its capacity in three cells, and the rents there — 18 in cold, 6
and 126 in severe — sum to 150, its cost to build. peak is at capacity in one
cell, and its rent there is 120, its cost to build. A capacity that is chosen
once is paid for by every future that runs it out.
What it exercises¶
Two variables that deliberately span different dimensions, coupled by a
constraint whose frame is the wider of the two, and an objective that reduces one
of them against a probability. The claim is not that the math is hard — it is
that structure comes from data: nothing here declares a stage, and the two
stages are visible only in which dimensions each foreach lists.