PyPSA fixed by data — a schedule, and a capacity already decided¶
A row of data that is present pins its variable; a row that is absent leaves it free.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 49900.0, matched to
rtol=1e-09.
p_set pins a dispatch, p_nom_set pins a capacity, and each equality exists
only where the table has a row: the must-run unit, the pre-committed schedule,
the capacity somebody already signed for.
chp is the dearest generator in the fleet and still runs in the two snapshots
it is scheduled in. That is the direction that matters — a fixing which only
ever agreed with the merit order would never show up in the objective.
The model¶
The same model, as math
PyPSA dispatch and capacity fixed by data: a row that is present pins its variable, a row that is absent leaves it free. A must-run unit runs in the two snapshots it is pinned in even though it is the dearest in the fleet. Optimum 49900.0, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) --- snapshot --- dispatch periods |
| \(\mathcal{B}\) | index \(b\) --- bus --- network nodes |
| \(\mathcal{G}\) | index \(g\) --- generator with \(\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B}\) --- generating units, each sitting on one bus |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(p^{\mathrm{nom,max}}\) | p_nom_max over \(\mathcal{G}\) --- most capacity that may stand at a generator once built |
| \(\mathit{capital\_cost}\) | capital_cost over \(\mathcal{G}\) --- cost of holding one unit of capacity over the horizon |
| \(\mathit{marginal\_cost}\) | marginal_cost over \(\mathcal{G}\) --- cost of one unit of output |
| \(p^{\mathrm{nom,set}}\) | p_nom_set over \(\mathcal{G}\) --- the capacity a generator is to hold, for the generators whose capacity is already decided |
| \(p^{\mathrm{set}}\) | p_set over \(\mathcal{T} \times \mathcal{G}\) --- the output a generator is to deliver, for the snapshots in which it is scheduled rather than chosen |
| \(\mathit{load}\) | load over \(\mathcal{T} \times \mathcal{B}\) --- demand at each bus in each snapshot |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) --- output of a generator in a snapshot |
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{G}\) --- capacity built at a generator |
Objective¶
Subject to¶
within_capacity
capacity_fixed
dispatch_fixed
nodal_balance
Variable domains¶
p
p_nom
The tabs start from the instance’s tables — one frame per parameter.
description: >-
PyPSA dispatch and capacity fixed by data: a row that is present pins its
variable, a row that is absent leaves it free. A must-run unit runs in the two
snapshots it is pinned in even though it is the dearest in the fleet.
Optimum 49900.0, from PyPSA itself.
dimensions:
snapshot:
description: dispatch periods
dtype: int
bus:
description: network nodes
dtype: str
generator:
description: generating units, each sitting on one bus
dtype: str
lookups:
gen_bus:
description: the bus a generator sits on
over: generator
into: bus
parameters:
p_nom_max:
description: most capacity that may stand at a generator once built
dims: [generator]
capital_cost:
description: cost of holding one unit of capacity over the horizon
dims: [generator]
marginal_cost:
description: cost of one unit of output
dims: [generator]
p_nom_set:
description: >-
the capacity a generator is to hold, for the generators whose capacity is
already decided
dims: [generator]
p_set:
description: >-
the output a generator is to deliver, for the snapshots in which it is
scheduled rather than chosen
dims: [snapshot, generator]
load:
description: demand at each bus in each snapshot
dims: [snapshot, bus]
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
upper: p_nom_max
constraints:
within_capacity:
description: a generator produces no more than the capacity built for it
foreach: [snapshot, generator]
expression: p <= p_nom
capacity_fixed:
description: >-
a generator whose capacity is already decided holds exactly that — the
row exists only where the table has one
foreach: [generator]
where: p_nom_set
expression: p_nom == p_nom_set
dispatch_fixed:
description: >-
a generator scheduled for a snapshot delivers exactly its schedule there,
and is free everywhere else
foreach: [snapshot, generator]
where: p_set
expression: p == p_set
nodal_balance:
description: what is generated at a bus meets the load there
foreach: [snapshot, bus]
expression: sum(p, by=gen_bus) == load
objective:
sense: minimize
description: what the fleet costs to run, plus what its capacity costs to build
expression: p * marginal_cost + p_nom * capital_cost
The model-building half of examples/ports/references/pypsa/pypsa_fixed.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``.
Both fixings arrive as short frames and are widened to the shape PyPSA
reads, with ``NaN`` where the port simply has no row: ``p_nom_set``
reindexed onto the generator index, ``p_set`` pivoted to snapshots by names.
NaN is PyPSA's own spelling for *not fixed here* — it is what its mask
tests — so the widening is the translation, not a defaulting choice.
Every generator is extendable, because ``p_nom_set`` fixes the capacity
*variable*: a non-extendable component has none for the equality to bind.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'])
n.add('Bus', tables['bus']['bus'])
generators: pd.DataFrame = tables['generator'].set_index('generator')
p_set = (
tables['p_set']
.pivot(index='snapshot', columns='generator', values='value')
.reindex(index=n.snapshots, columns=generators.index)
)
n.add(
'Generator',
generators.index,
bus=generators['gen_bus'],
p_nom_extendable=True,
p_nom_max=tables['p_nom_max'].set_index('generator')['value'],
p_nom_set=tables['p_nom_set'].set_index('generator')['value'].reindex(generators.index, fill_value=np.nan),
capital_cost=tables['capital_cost'].set_index('generator')['value'],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
p_set=p_set,
)
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])
return n
Two partial tables, at different ranks. p_set is sparse over (snapshot,
generator) — two rows, both chp — and p_nom_set over (generator) alone.
The mask is the entire feature, so a rung that pinned everything would prove
nothing. Both constraints carry where: naming the parameter, which is the
language's spelling for the rows this table has; PyPSA spells the same thing
as NaN in a widened frame and tests ~isnull().
The capacity fixing needs a capacity variable. Every generator here is
extendable, including the two whose capacity is pinned. A non-extendable
component has no variable for the equality to bind, so p_nom_set would be
silently ignored — which is why the port declares p_nom over every generator
and lets the data decide which of them are still a decision.
What it exercises¶
where: on an equality, at two ranks, against a partial table on the constant
side — the case the language refuses to guess at, since a missing row read as
zero would pin the variable to zero rather than leave it free.
A note on the instance¶
gas carries a p_nom_max of 200 it never approaches. An earlier draft capped
it at 60, exactly the capacity it builds, and that coincidence made the problem
dual-degenerate: with the build limit active at the same snapshot as the
capacity limit, 80 and 90 are both optimal nodal prices, and the two
implementations picked differently. The objective agreed throughout. Lifting the
limit off the optimum makes the dual unique, which is what a recorded dual
vector needs to be worth asserting.