Skip to content

PyPSA LOPF — rung 1

PyPSA linear optimal power flow, first rung: transport model, linear marginal cost, no KVL.

✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 22000, matched to rtol=1e-09.

The model

# PyPSA linear optimal power flow, rung 1: transport model, linear marginal
# cost, no KVL. Optimum 22000.0, from PyPSA itself. See docs/ports.md.

dimensions:
  snapshot:
    dtype: int
  bus:
    dtype: str
  generator:
    dtype: str
    coords: [bus]                  # every generator sits on a bus
  link:
    dtype: str
    coords: {from: bus, to: bus}   # both endpoints are buses

parameters:
  p_nom:
    dims: [generator]
  marginal_cost:
    dims: [generator]
  rating:
    dims: [link]
  neg_rating:
    dims: [link]
  load:
    dims: [snapshot, bus]

variables:
  p:
    foreach: [snapshot, generator]
    bounds:
      lower: 0
      upper: p_nom
  # PyPSA's `p0`: flow measured at the link's `from` end, so a positive value
  # withdraws there and injects at `to`. `p_min_pu = -1` makes it bidirectional.
  f:
    foreach: [snapshot, link]
    bounds:
      lower: neg_rating
      upper: rating

constraints:
  nodal_balance:
    foreach: [snapshot, bus]
    equations:
      - expression: group_sum(p, over=generator, by=bus) + group_sum(f, over=link, by=to) - group_sum(f, over=link, by=from) == load

objectives:
  total_cost:
    sense: minimize
    equations:
      - expression: p * marginal_cost

Side by side

PyPSAexamples/ports/references/pypsa_transport.py
from __future__ import annotations

import json
from pathlib import Path

import pandas as pd
import pypsa

DATA = Path(__file__).resolve().parent.parent / 'data' / 'pypsa_transport.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', data['bus']['bus'])

    n.add(
        'Generator',
        data['generator']['generator'],
        bus=data['generator']['bus'],
        p_nom=data['p_nom']['value'],
        marginal_cost=data['marginal_cost']['value'],
    )
    # `p_min_pu = -1` makes a link bidirectional. The port cannot say that in a
    # bound — bounds take a name or a number, never arithmetic (SPEC §2) — so
    # it ships `neg_rating` as data instead. That is the ledger row.
    n.add(
        'Link',
        data['link']['link'],
        bus0=data['link']['from'],
        bus1=data['link']['to'],
        p_nom=data['rating']['value'],
        p_min_pu=-1.0,
        efficiency=1.0,
    )

    load = pd.DataFrame(data['load']).pivot(index='snapshot', columns='bus', values='value')
    for bus in data['bus']['bus']:
        n.add('Load', f'load_{bus}', bus=bus, p_set=load[bus])
    return n


def nodal_prices(n: pypsa.Network) -> dict[str, list]:
    """PyPSA's marginal price per (snapshot, bus), tidy — the dual of the nodal
    balance, and the output this community reads most often after the cost.

    Recorded in references.json so the port is checked on a whole *vector*, not
    just the objective. A sign convention that disagreed would be invisible to
    a scalar comparison and wrong in every reported price.
    """
    mp = n.buses_t.marginal_price
    return {
        'snapshot': [s for s in mp.index for _ in mp.columns],
        'bus': [b for _ in mp.index for b in mp.columns],
        'value': [float(v) for row in mp.to_numpy() for v in row],
    }


def main() -> float:
    n = build(json.loads(DATA.read_text()))
    n.optimize(solver_name='highs')
    print(f'pypsa {pypsa.__version__}')
    print(f'objective {float(n.objective)!r}')
    print(f'duals {json.dumps({"nodal_balance": nodal_prices(n)})}')
    return float(n.objective)


if __name__ == '__main__':
    main()

Read this comparison carefully — it flatters neither side fairly. PyPSA is a domain package: n.add('Generator', ...) and n.add('Link', ...) carry a power-systems model inside them, so the reference is short because someone already wrote the power flow. Against that, the YAML looks more explicit rather than shorter, and it should — it is stating the constraint PyPSA implies.

The comparison against a general-purpose alternative is on the Dantzig page, where both sides write the maths out.

What it exercises

Rung 1 of a ladder. Reproducing a full PyPSA objective means reproducing marginal and capital cost, ramp limits, storage cycling and KVL at once, and a mismatch then implicates five features instead of one. So each feature is switched off in PyPSA and reproduced here separately: 1 transport model (this one) · 2 ramp limits · 3 storage with state of charge · 4 cyclic boundary condition · 5 KVL.

This rung hit the ceiling once, and that is recorded rather than worked around quietly: PyPSA's p_min_pu = -1 is a bound of -rating, an expression this language cannot yet put in bounds:. It ships as a neg_rating column instead, and the gap is issue #31 with the verdict primitive. See the ledger in ports.md.


examples/ports/pypsa_transport.yaml · back to all models