Skip to content

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

# PyPSA unit commitment: binary status per generator per snapshot, with
# start-up and shut-down charges. Optimum 24900.0, from PyPSA itself.
# See docs/ports.md.

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]
    equations:
      - 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:
    foreach: [snapshot, generator]
    equations:
      - expression: p - p_nom * status <= 0
      - 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.
  transition:
    foreach: [snapshot, generator]
    equations:
      - expression: start_up - status >= -1
        where: "snapshot == 0"
      - expression: start_up - status + roll(status, snapshot=1) >= 0
        where: "snapshot > 0"
      - expression: shut_down + status >= 1
        where: "snapshot == 0"
      - expression: shut_down + status - roll(status, snapshot=1) >= 0
        where: "snapshot > 0"

objectives:
  total_cost:
    sense: minimize
    equations:
      - expression: p * marginal_cost + start_up * start_up_cost + shut_down * shut_down_cost

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. farkas and PyPSA agree on the schedule as well as the cost.

Side by side

from __future__ import annotations

import json
from pathlib import Path

import pandas as pd
import pypsa

DATA = Path(__file__).resolve().parent.parent / 'data' / 'pypsa_unit_commitment.json'


def build(data: dict[str, dict[str, list]]) -> pypsa.Network:
    """The port's tables as a PyPSA network, column for column."""
    n = pypsa.Network()
    n.set_snapshots(data['snapshot']['snapshot'])
    n.add('Bus', 'bus')

    n.add(
        'Generator',
        data['generator']['generator'],
        bus='bus',
        committable=True,
        p_nom=data['p_nom']['value'],
        marginal_cost=data['marginal_cost']['value'],
        p_min_pu=data['p_min_pu']['value'],
        start_up_cost=data['start_up_cost']['value'],
        shut_down_cost=data['shut_down_cost']['value'],
    )

    load = pd.Series(data['load']['value'], index=data['load']['snapshot'])
    n.add('Load', 'load', bus='bus', p_set=load)
    return n


def main() -> float:
    n = build(json.loads(DATA.read_text()))
    status, condition = n.optimize(solver_name='highs')
    assert status == 'ok', f'{status}: {condition}'
    print(f'pypsa {pypsa.__version__}')
    print(f'objective {float(n.objective)!r}')
    print(n.generators_t.p)
    print(n.generators_t.status)
    return float(n.objective)


if __name__ == '__main__':
    main()

What it exercises

binary variables and the integrality path through to HiGHS, roll across a boundary condition, and a three-term objective mixing an energy cost with two transition charges.