PyPSA unit commitment¶
Which generators are on, not just how much they produce — a binary per generator per snapshot, with start-up and shut-down charges.
✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 24900, matched to
rtol=1e-09.
The corpus's MILP entry. Every other verified model is a pure continuous LP; this one carries integrality, which is what the gallery's construct matrix had no verified example of. One bus and no network, deliberately: a model that fails to match should implicate one feature, and here that feature is commitment.
min_up_time and min_down_time are left at 0. They need a rolling window sum
over a horizon, which is a different question from whether the language can say
commitment at all — see the ledger.
The model¶
The same model, as math
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) --- snapshot |
| \(\mathcal{G}\) | index \(g\) --- generator |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{G}\) |
| \(\mathit{marginal\_cost}\) | marginal_cost over \(\mathcal{G}\) |
| \(p^{\mathrm{min,pu}}\) | p_min_pu over \(\mathcal{G}\) |
| \(\mathit{start\_up\_cost}\) | start_up_cost over \(\mathcal{G}\) |
| \(\mathit{shut\_down\_cost}\) | shut_down_cost over \(\mathcal{G}\) |
| \(\mathit{load}\) | load over \(\mathcal{T}\) |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) |
| \(\mathit{status}\) | status over \(\mathcal{T} \times \mathcal{G}\) |
| \(\mathit{start\_up}\) | start_up over \(\mathcal{T} \times \mathcal{G}\) |
| \(\mathit{shut\_down}\) | shut_down over \(\mathcal{T} \times \mathcal{G}\) |
Objective¶
total_cost
Subject to¶
power_balance
commitment_max
commitment_min
start_up_initial
start_up
shut_down_initial
shut_down
Variable domains¶
p
status
start_up
shut_down
The tabs start from the instance’s tables — one frame per parameter.
# PyPSA unit commitment: binary status per generator per snapshot, with
# start-up and shut-down charges. Optimum 24900.0, from PyPSA itself.
dimensions:
snapshot:
dtype: int
generator:
dtype: str
parameters:
p_nom:
dims: [generator]
marginal_cost:
dims: [generator]
p_min_pu:
dims: [generator]
start_up_cost:
dims: [generator]
shut_down_cost:
dims: [generator]
load:
dims: [snapshot]
variables:
p:
foreach: [snapshot, generator]
bounds:
lower: 0
# All three are binary in PyPSA. start_up and shut_down are implied by the
# transitions below, but PyPSA declares them integral rather than leaving it
# to the status variables, and the port matches that.
status:
foreach: [snapshot, generator]
binary: true
start_up:
foreach: [snapshot, generator]
binary: true
shut_down:
foreach: [snapshot, generator]
binary: true
constraints:
power_balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
# A committed unit runs between p_min_pu * p_nom and p_nom; an uncommitted
# one is pinned to zero from both sides. `p_nom * status` is a parameter
# against a variable, so the product stays degree 1.
commitment_max:
foreach: [snapshot, generator]
expression: p - p_nom * status <= 0
commitment_min:
foreach: [snapshot, generator]
expression: p - p_min_pu * p_nom * status >= 0
# start_up must be 1 on a snapshot where status rises, shut_down where it
# falls. The first snapshot has no predecessor, and PyPSA's default is that
# the unit was already up before the horizon — so the start-up row is
# slackened to -1 there (never binding) while the shut-down row still
# charges a unit that begins the horizon off. That asymmetry is PyPSA's, and
# it is worth 50 on this instance.
start_up_initial:
foreach: [snapshot, generator]
where: "snapshot == 0"
expression: start_up - status >= -1
start_up:
foreach: [snapshot, generator]
expression: start_up - status + shift(status, over=snapshot, by=1) >= 0
shut_down_initial:
foreach: [snapshot, generator]
where: "snapshot == 0"
expression: shut_down + status >= 1
shut_down:
foreach: [snapshot, generator]
expression: shut_down + status - shift(status, over=snapshot, by=1) >= 0
objectives:
total_cost:
sense: minimize
expression: p * marginal_cost + start_up * start_up_cost + shut_down * shut_down_cost
The model-building half of examples/ports/references/pypsa/pypsa_unit_commitment.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``.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'])
n.add('Bus', 'bus')
generators: pd.DataFrame = tables['generator'].set_index('generator')
n.add(
'Generator',
generators.index,
bus='bus',
committable=True,
p_nom=tables['p_nom'].set_index('generator')['value'],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
p_min_pu=tables['p_min_pu'].set_index('generator')['value'],
start_up_cost=tables['start_up_cost'].set_index('generator')['value'],
shut_down_cost=tables['shut_down_cost'].set_index('generator')['value'],
)
load: pd.Series = tables['load'].set_index('snapshot')['value']
n.add('Load', 'load', bus='bus', p_set=load)
return n
The first snapshot is not like the others. PyPSA's default is that a unit
was already up before the horizon began, so the start-up row is slackened to
>= -1 there and never binds, while the shut-down row still charges a unit
that begins the horizon off. peak does, so the instance pays a shut-down it
never visibly performs. That asymmetry is PyPSA's, it is worth 50 here, and
reproducing it is most of what makes this a fidelity test rather than a
plausible-looking rewrite.
Two where clauses on one constraint block is how the language says "this row
differs at the boundary" — the same shape storage uses for its
initial state of charge.
What it costs¶
| energy | 30 × 520 + 90 × 100 = 24600 |
| start-ups | peak at snapshot 1 = 200 |
| shut-downs | peak at snapshot 0 (begins off) and at 3 = 100 |
| total | 24900 |
base runs throughout; peak covers the two peak snapshots. lpspec and PyPSA
agree on the schedule as well as the cost.
What it exercises¶
binary variables and the integrality path through to HiGHS, shift across a
boundary condition, and a three-term objective mixing an energy cost with two
transition charges.