Tutorial 2 · DC-OPF and N-1 screening¶
Difficulty: intermediate · ~6–8 min read
In tutorial 1 we ran a power flow with a fixed generator
dispatch — every generator's p_mw was already decided, and the solver just found the
resulting voltages and flows. This tutorial lets the dispatch itself be chosen: given each
generator's cost curve and its bounds, what dispatch minimises total cost? And once we have
that dispatch, is the network still safe if any single branch trips?
We'll use the same case14 fixture as tutorial 1. As with every tutorial here, that needs a
clone of the repository on disk — pip install mambo-power alone doesn't give you the bundled
MATPOWER fixtures; see Getting started if you haven't already got one.
from mambo_power import contingency, opf, pf
from mambo_power.io import matpower
net = matpower.load("../../fixtures/matpower/case14.m")
DC-OPF: the cheapest dispatch¶
opf.solve_dc_opf finds the cost-minimising generator dispatch subject to the same linearised
network pf.solve_dc solves, plus each generator's [p_min_mw, p_max_mw] bounds and any
per-branch flow limit. In plain terms: every generator has a cost curve (dollars per hour as a
function of its output), and the solver picks how much each one produces so that total demand is
met at minimum total cost, without exceeding what any generator or branch can carry. Under the
hood it's a linear (or, when a generator's cost curve is quadratic — as every generator in
case14 has — quadratic) program solved by HiGHS. The full formulation,
including how piecewise-linear costs are encoded, is on the DC-OPF manual
page.
result = opf.solve_dc_opf(net)
print(f"status: {result.status} cost: {result.objective_cost:.2f} $/h")
print(f"balance dual (system energy price): {result.balance_dual:.4f} $/MWh")
print("dispatch:")
for g in result.generators:
pinned = " (pinned at a bound)" if g.bound_dual != 0.0 else ""
print(f" {g.id:8s} {g.bus:8s} {g.p_mw:8.3f} MW{pinned}")
status: Optimal cost: 7642.59 $/h balance dual (system energy price): 39.0162 $/MWh dispatch: gen-1 bus-1 220.968 MW gen-2 bus-2 38.032 MW gen-3 bus-3 0.000 MW (pinned at a bound) gen-4 bus-6 0.000 MW (pinned at a bound) gen-5 bus-8 0.000 MW (pinned at a bound)
Locational marginal prices (LMPs)¶
Every bus in the result also carries a locational marginal price: the cost of serving one more MW of demand at that specific bus, right now, given the current dispatch and network constraints. An LMP splits into two pieces — an energy component (the same everywhere, equal to the system-wide cost of the next MW) and a congestion component (how much more it costs at this bus specifically, because some branch's capacity is standing in the way).
case14 as shipped has no branch with a real thermal rating (every RATE_A reads 0, MATPOWER's
"unlimited" convention), so nothing can bind and every bus's LMP is pure energy — the same
number everywhere. (The manual page shows what a congested LMP split looks like once a rating
actually binds.)
for b in result.buses[:3]:
print(f" {b.id}: lmp {b.lmp:7.3f} energy {b.energy:7.3f} congestion {b.congestion:7.3f}")
bus-1: lmp 39.016 energy 39.016 congestion 0.000 bus-2: lmp 39.016 energy 39.016 congestion 0.000 bus-3: lmp 39.016 energy 39.016 congestion 0.000
N-1 contingency screening¶
A dispatch that's cheapest and perfectly safe right now might not stay safe if a single branch trips — a transformer fails, a line is struck by lightning, a breaker opens for maintenance. N-1 screening asks, for every branch in turn: if this one branch were taken out of service, would any other branch end up carrying more power than its rating allows? It's called "N-1" because it checks the network with any one of its N branches removed — the standard first line of defense grid operators use before ever calling a dispatch "safe."
contingency.n1 runs a fast linear estimate (via the LODF sensitivity matrix) for every
branch outage that wouldn't disconnect the network, then confirms every flagged outage with a
real re-solve. See N-1 screening for the full two-stage pipeline and the
brute-force agreement proof behind it.
case14 as shipped, like every bundled fixture, carries no real branch ratings — so there's
nothing for the screen to flag until we give it something to check against. We derive a
synthetic rating from each branch's own base-case flow (20% of headroom above what it's already
carrying) — a documented transformation of data the fixture already owns, not new data, and the
same one this package's own example script and test suite use.
base = pf.solve_dc(net)
rated = net.model_copy(deep=True)
base_flow_mw = {b.id: abs(b.p_from_mw) for b in base.branches}
for br in rated.branches:
if br.id in base_flow_mw:
br.rating_mva = max(1.2 * base_flow_mw[br.id], 1.0)
n1_result = contingency.n1(rated)
screenable = len(rated.branches) - len(n1_result.bridge_branch_ids)
print(
f"{len(n1_result.outages)} outages flagged, out of {screenable} screenable branches "
f"({len(n1_result.bridge_branch_ids)} bridge branch skipped: "
f"its outage would island the network)"
)
18 outages flagged, out of 19 screenable branches (1 bridge branch skipped: its outage would island the network)
Interpreting one violation¶
Let's look at the first flagged outage in detail: what happens to the rest of the network if that one branch goes out of service.
outage = n1_result.outages[0]
print(f"outage: {outage.outage_branch_id} confirmed violating: {outage.confirmed_violating}\n")
for flag in outage.flagged_branches[:3]:
print(
f" {flag.branch_id:10s} rating {flag.rating_mva:7.2f} MVA "
f"screened estimate {flag.estimated_flow_mw:7.2f} MW "
f"confirmed flow {flag.confirmed_flow_mw:7.2f} MW violating={flag.confirmed_violating}"
)
outage: branch-1 confirmed violating: True branch-2 rating 85.39 MVA screened estimate 219.00 MW confirmed flow 219.00 MW violating=True branch-6 rating 29.02 MVA screened estimate 49.15 MW confirmed flow 49.15 MW violating=True branch-7 rating 74.10 MVA screened estimate 134.68 MW confirmed flow 134.68 MW violating=True
Reading this: with branch-1 taken out of service, branch-2 — which shares the same
corridor out of the slack bus — has to absorb the power branch-1 was carrying, and its flow
rises past its (synthetic) rating. The LODF-based screen's estimate of that post-outage flow
and the confirming DC re-solve agree to five decimal places here, which is not a coincidence:
the agreement guarantee proves the screen misses
nothing a brute-force sweep would catch, on every bundled fixture. In a real control room this is
exactly the finding that would tell an operator "don't take branch-1 out of service without doing
something else first" — the something else (redispatch, a switching action) is outside what
this screening step does; it reports the violation, it doesn't fix it.
Next¶
Tutorial 3 — nodal market clearing keeps the same optimisation machinery but changes what's being optimised: instead of a single planner minimising cost against a fixed demand, generators submit offers and demand itself can be price-responsive, and the market clears at a price that has to be paid, not just computed.