Introduction

gridoxide is an AC power flow library written in Rust. At its core it solves the power flow equations with the Newton-Raphson method, using a sparse Jacobian throughout — assembly, factorization, and solve — and the other analyses it offers are built on that same core rather than beside it.

That reuse is the recurring theme, and it is worth naming up front. The state estimator's branch flow derivatives are what the sensitivity analysis differentiates with. The Jacobian Newton assembles each iteration is the matrix the sensitivities factorize. The sequence-domain parameters the unbalanced power flow needs are what the short-circuit calculation assembles its fault network from. Each capability cost far less than it would have standalone, and several of them found latent bugs in the shared code on their way in.

This book covers both the method (what the equations are, how the sparse solve works, what each modeling feature changes about the equation system) and the tool (how to build it, which linear solver backends exist, how CGMES input is mapped onto the internal network model).

Most chapters end in a worked example small enough to check with a calculator — a fault current, a weighted-least-squares estimate, a congested two-bus market, a phase shifter moved to relieve an overload. Every figure in them was produced by running the code on the input shown, and the inputs themselves are committed under docs/examples/. Where the arithmetic is done by hand instead, the page says so.

Where to start

  • Getting Started — build the Rust project, run a solve, or pip install gridoxide and drive it from Python.
  • Power Flow — the Newton-Raphson formulation, the two linearizations (DC and constant-admittance), and the modeling features that change the equation system: reactive power limits, zero-impedance branches, and multiple islands.
  • Sensitivity Analysis — differentiating a converged operating point: what responds when an injection or a tap moves, and what would move a quantity you are watching. (The DC factors, PTDF and LODF, live in the DC chapter alongside the method they are exact for.)
  • Optimal Power Flow — least-cost dispatch in DC and AC, and the prices that come out with it; worked on two buses where one line decides whether they separate.
  • Short Circuit — IEC 60909 fault currents in the phase domain, the four fault types and their boundary conditions, the symmetrical-component algebra behind them, and a fault current computed by hand.
  • State Estimation — the weighted-least-squares problem, what each measurement type means, observability, bad-data detection, and an estimate worked end to end.
  • Remedial Action Optimization — given contingencies, monitored elements and the actions an operator may take, what to do: margins, the linear program over continuous controls, and the search tree over discrete ones.
  • Sparse Linear Solvers — the five interchangeable linear-solver backends, and a step-by-step walkthrough of the KLU algorithm all of them are measured against.
  • CGMES Data Model — reading ENTSO-E RDF/XML grid models, and how individual CIM classes map onto buses, branches, and injections.
  • Reference — how gridoxide compares against five other power flow tools, where the benchmark numbers live, and the licensing of every vendored and translated piece of third-party code.

Building and Running

Building

You need the Rust toolchain — see rustup.rs for installation instructions. Then:

cargo build

For an optimized release build:

cargo build --release

A default build needs no C compiler, no system libraries, and no environment variables. The opf feature (optimal power flow) keeps that property — it is pure Rust and adds no dependencies. The capi feature (C and C++ API) keeps that property too — pure Rust plus a build-time crate, nothing to install. Everything else — the klu, pardiso, opf-highs, opf-ipopt, cgmes, and python features — is opt-in and needs something installed; each is described on the page that covers it (backends, CGMES input, Python bindings).

Running

With no arguments the binary runs a bundled power-flow demo:

cargo run

Or run the built executable directly from the project root:

./target/debug/gridoxide     # debug build
./target/release/gridoxide   # release build

Subcommands

gridoxide --help prints the full argument list. Each subcommand reads a power-grid-model JSON document, except switches, which reads CGMES profiles.

CommandWhat it does
estimate <path> [--iterative-linear]State estimation over a document containing sensors. Newton-Raphson by default; the flag selects the faster linearized method.
dc <path> [--ignore-g] [--ptdf <bus>] [--lodf <branch>]DC power flow, optionally printing one sensitivity column.
sensitivity <path> [--dp\|--dq <bus>] [--dk\|--dalpha <branch>] [--watch <branch>] [--terminal from\|to]AC sensitivity. The first four flags each pick one variable and report what responds; --watch picks one branch and reports what would move it.
short-circuit <path> [--scaling max\|min]IEC 60909 fault currents. --scaling picks the voltage factor c.
opf <network.json> [--ac] [--data <opf.json>] [--no-shedding] [--shed-price <$/MWh>] [--ignore-r] [--highs] [--no-limits] [--max-iter <n>]DC optimal power flow: least-cost dispatch subject to generator limits and branch ratings, reporting dispatch, locational marginal prices and what binds. Costs and limits come from a companion document, defaulting to <network>.opf.json. --ignore-r selects b = 1/x — note this is the opposite default from dc above, on purpose. --highs swaps the built-in interior-point solver for HiGHS. --ac solves the full AC problem instead — real voltages, reactive power and losses — which is nonconvex, so its answer is a local optimum; --no-limits drops branch ratings and --max-iter caps the solve. Needs the opf feature (--highs additionally needs opf-highs).
switches <profile.xml>… [--retain …] [--open <mrid>] [--solve]Node-breaker switching devices read from CGMES EQ+SSH. Needs the cgmes feature.

Bus and branch arguments are gridoxide's own 0-based indices, and branches are ordered lines first then transformers — the tables each command prints say which is which.

# Least-cost dispatch, and what the network is costing you.
cargo run --features opf -- opf grid.json

# The same question against the full AC equations.
cargo run --features opf -- opf grid.json --ac

# Which injection would relieve an overload on branch 8?
cargo run -- sensitivity grid.json --watch 8

# The largest three-phase fault current this network can produce.
cargo run -- short-circuit grid.json --scaling max

Argument handling is deliberately hand-rolled rather than pulled from a CLI crate: a handful of modes and one path each does not justify the dependency, and the library — not the binary — is the intended interface for anything more involved.

Testing

cargo test

Note that cargo test never compiles the python feature — the feature must not be combined with a plain cargo invocation at all (see Python bindings). Tests for the optional backends live in their own files (tests/block_jacobian_test.rs, tests/klu_jacobian_test.rs, tests/klu_native_jacobian_test.rs, tests/pardiso_jacobian_test.rs) and only run when the matching feature is enabled.

Next steps

To measure rather than just run, see Benchmarking and Profiling for the benchmark harnesses, the perf setup, and where the measured numbers are recorded.

Python Bindings

gridoxide ships as a pip-installable package exposing the solver to Python:

pip install gridoxide

Prebuilt wheels are published for Linux (x86_64), Windows, and macOS (arm64).

import gridoxide

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", backend="klu_native")
model.solve()
print(model.voltage_mag())  # per-unit magnitude, one entry per node
print(model.voltage_ang())  # angle in radians, one entry per node

Grids are loaded from power-grid-model (PGM) JSON input files. python/README.md in the repository is the package's own PyPI landing page and carries a full worked example of that input format.

API

  • PowerFlowModel.from_pgm_json(path, backend="scalar", tol=1e-6, max_iter=20, s_base_va=1e6, freq_hz=50.0, method="newton_raphson", dc_approximation="ignore_r") — loads a PGM JSON file and builds the Y-bus admittance matrix.
  • model.n_nodes / model.n_branches — bus count (including one virtual slack bus per active source) and branch count (lines first, then transformers — the index every branch-keyed vector below uses).
  • model.solve() — runs whichever method the model was built with; raises RuntimeError if it doesn't converge within max_iter iterations, or if a direct method's matrix is singular.
  • model.reset() — discards the cached symbolic factorization; call before the next solve() if the topology has changed.
  • model.voltage_mag() / model.voltage_ang() — per-bus results in node order.

Linear methods

method selects between three solvers. "newton_raphson" is the default and the full nonlinear solve; "dc" is real Bθ; "linear_impedance" is the complex constant-admittance linearization (power-grid-model's CalculationMethod.linear). The two are different algorithms rather than two spellings of one — see DC (Bθ) Power Flow and The Constant-Admittance Linearization.

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", method="dc")
model.solve()
flows = model.branch_flow_p()          # per-unit, at each branch's from terminal
pickup = model.dc_slack_pickup()       # [(bus_indices, p_pu)] per island
  • model.solve_dc() — runs a DC solve on a model built for any method, so a Newton model can take a DC reading without being rebuilt.
  • model.branch_flow_p() — per-branch active flow, per-unit. DC only; the other methods produce complex flows, computed from the solved voltages.
  • model.dc_slack_pickup() / model.dc_max_residual() — per-island reference supply, and the solve's residual (round-off on a healthy network; large means B was ill-conditioned).
  • dc_approximation is "ignore_r" (default, b = 1/x, what every other tool computes) or "ignore_g" (b = x/(r²+x²), the exact coefficient). They differ only where r/x varies across the network — a uniform ratio makes the difference a pure rescaling of the angles.

DC sensitivity factors (PTDF and LODF)

PTDF and LODF are properties of the topology, not of an operating point, so these need no solve() first. The factorization behind them is built on first use and reused until reset().

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json")
overloaded = model.ptdf_row(branch=12)         # sensitivity to every bus, one solve
if not model.is_radial(7):
    redistribution = model.lodf_column(7)      # who picks up branch 7's flow if it trips
  • model.ptdf_column(bus) — ∂P_branch/∂P_bus over every branch. None if the bus is in an island with no reference.
  • model.ptdf_row(branch) — the same factors over every bus, in one solve rather than one per bus (the reduced susceptance matrix is symmetric).
  • model.lodf_column(branch) — the fraction of that branch's flow each other branch picks up when it trips. None if the branch is radial.
  • model.is_radial(branch) — whether removing it would disconnect the network, in which case no redistribution factors exist.
  • model.transfer_factors(injections) — branch-flow response to an arbitrary per-bus injection pattern; the primitive the others are special cases of.
  • model.outage_flows(branch, base_flows=None) — the N-1 primitive: flows after that branch trips, one solve and no re-solve of the network. Defaults to the last DC solve's own flows, so the usual call is model.solve(); model.outage_flows(7).
  • model.multi_outage_flows(branches, base_flows=None) — N-2 and N-k, for a whole set tripping at once. Not the same as calling outage_flows repeatedly: each single-branch factor was computed on the intact network, so chaining them ignores how the outages interact.
  • model.is_breaking_set(branches) — whether removing the whole set would disconnect the network. Two individually non-radial lines can be jointly breaking, which is exactly what single-branch screening misses.

AC sensitivity

The DC factors above are exact but blind to voltage and reactive power. AcSensitivityModel differentiates a converged AC operating point instead — so it has a solve behind it, and the answers are local derivatives rather than global constants. See AC Sensitivity Analysis for the formulation and the trade-off.

It is a class rather than a function because there is something worth keeping between calls: one Jacobian factorization, arbitrarily many questions.

s = gridoxide.AcSensitivityModel("grid.json")

col = s.column(active_injection=2)   # bus 2 ramps — what responds?
row = s.row(branch=8)                # branch 8 is loaded — what moves it?

# Which tap has the most leverage on branch 8?
best = max(range(s.n_branches), key=lambda b: abs(row.d_transformer_ratio[b]))
  • AcSensitivityModel(path, s_base_va=1e6, freq_hz=50.0, tol=1e-8, max_iter=50) — solves the power flow and factorizes its Jacobian. Raises if the solve does not converge: a derivative taken at a non-converged point is meaningless rather than merely imprecise.
  • s.column(...) — the forward direction, one solve per variable. Pass exactly one of active_injection=bus, reactive_injection=bus, transformer_ratio=branch or phase_shift=branch, plus terminal="from"|"to". Returns d_branch_active, d_branch_reactive (per branch) and d_voltage_magnitude, d_voltage_angle (per bus).
  • s.row(...) — the adjoint direction, one solve per monitored quantity. Pass either branch= with quantity="active"|"reactive", or bus= with quantity="magnitude"|"angle". Returns d_active_injection, d_reactive_injection (per bus) and d_transformer_ratio, d_phase_shift (per branch).
  • s.n_buses, s.n_branches — branch indices are lines first, then transformers.

Pick the direction by which side is small: forward when few inputs move and many outputs are watched, adjoint when one output is watched and many inputs could move. Both cost one triangular solve against the same factorization.

Two contracts worth knowing. A variable the solve itself determines has an identically zero column — a slack bus's injection, or reactive power where the voltage is held — and that is the correct derivative, not a gap. And a line has no tap, so its d_transformer_ratio and d_phase_shift entries are zero rather than an error.

DC optimal power flow

dc_opf answers what should each generator produce, so demand is met at least cost without overloading anything. A plain function rather than a class: each solve builds its own program, so there is nothing worth keeping between calls.

Only present when the extension was built with the opf feature. That feature needs nothing installed — the solver is gridoxide's own interior-point method, pure Rust — so enabling it costs only build time. Importing gridoxide never fails for want of a solver; dc_opf is simply absent, so hasattr(gridoxide, "dc_opf") is the check.

import gridoxide

result = gridoxide.dc_opf("grid.json")

print(result.objective)                       # total cost, $/h
for index, mw in zip(result.generator_index, result.dispatch):
    print(index, mw)

# The price spread between buses *is* the congestion.
spread = max(result.lmp) - min(result.lmp)
for b in result.binding:
    print(f"branch {b.branch} at {b.flow:.1f}/{b.rate:.1f} MW, "
          f"worth {abs(b.price):.2f} $/MWh to relieve")
  • dc_opf(path, data_path=None, shed_price=10000.0, allow_shedding=True, dc_approximation="ignore_g", solver="ipm", freq_hz=50.0) — costs and limits come from a companion OPF document, defaulting to path with its extension replaced by .opf.json, which is the pair gridoxide-matpower writes. Raises if no optimal dispatch exists.
  • solver is "ipm" (default), the built-in interior-point method, or "highs", available only when the extension was also built with opf-highs (which links a local HiGHS). The two are cross-checked against each other, so this picks a dependency rather than an answer — see Optimal Power Flow.
  • dc_approximation defaults to "ignore_g" (b = x/(r²+x²)), the opposite of PowerFlowModel.from_pgm_json's default and deliberately so — each matches what its own field's reference tools compute. The choice moves which branch binds, so it is not cosmetic; see Optimal Power Flow.
  • result.objective — total cost, $/h.
  • result.dispatch / result.generator_index — MW per generator, and the source case's own generator index for each, so results match back to the case file.
  • result.lmp — locational marginal price per bus, $/MWh: the cost of serving one more MW there. Uniform when nothing is congested.
  • result.flows, result.angles — per branch and per bus.

AC optimal power flow

ac_opf solves the full problem dc_opf linearizes — real voltage magnitudes, reactive power and losses, optimizing generator active and reactive output together.

r = gridoxide.ac_opf("grid.json")

print(f"{r.objective:.2f} $/h in {r.iterations} iterations")
print(f"largest violation: {r.violation:.2e} pu")
print(f"voltage range: {min(r.magnitudes):.4f} - {max(r.magnitudes):.4f} pu")

for index, p, q in zip(r.generator_index, r.p_gen, r.q_gen):
    print(f"generator {index}: {p:8.2f} MW  {q:8.2f} MVAr")
  • ac_opf(path, data_path=None, enforce_limits=True, max_iterations=300, tolerance=1e-8, freq_hz=50.0) — the companion document supplies costs, branch ratings and per-bus voltage limits. Raises if no first-order point is reached.
  • result.p_gen / result.q_gen — MW and MVAr per generator.
  • result.magnitudes, result.angles — per-unit and radians, per bus.
  • result.lmp_p, result.lmp_q — active and reactive prices, $/MWh and $/MVArh.
  • result.flows — (P, Q) entering each branch at its from-terminal.
  • result.violation — part of the answer, not diagnostics. AC-OPF is nonconvex, so the objective is a local optimum and a lower cost at an infeasible point is not a better one. Reported so a caller can tell the two apart; see Optimal Power Flow.
  • result.shed — MW of unserved demand per load. All zero on a case that can be served; with allow_shedding=False such a case raises instead, which is sometimes the answer wanted.
  • result.binding — the branches at their limit, each with branch, flow, rate and price.

Costs, limits and ratings are not in the PGM network document — it has nowhere to put them — so gridoxide-matpower writes both files from a MATPOWER case:

python -m gridoxide.matpower case14.m case14.json    # also writes case14.opf.json

Short-circuit calculation

short_circuit is a plain function, not a class: it is a single direct solve with nothing worth caching between calls, because the fault's boundary conditions change the matrix itself. The fault is declared in the document as power-grid-model's fault component. See The Short-Circuit Problem.

result = gridoxide.short_circuit("grid.json", scaling="max")

for fault in result.faults:
    print(fault.id, fault.i_f)          # per-phase current, amperes

# A two-phase fault clear of ground has no zero-sequence component at all —
# the sharpest available check that a result is sane.
for node in result.nodes:
    zero, positive, negative = node.sequence
  • short_circuit(path, scaling="max", s_base_va=1e6, freq_hz=50.0) — scaling picks the IEC 60909 voltage factor c: "max" for the largest current (what equipment ratings are sized against), "min" for the smallest (the protection-sensitivity study).
  • result.nodes — per node: u_pu, u_angle, u (line-to-neutral volts), energized, and sequence as (zero, positive, negative) magnitudes.
  • result.faults — per fault: i_f, i_f_angle, per phase.
  • result.sources — each source's contribution, i and i_angle.

Node-breaker topology and switches

CGMES models carry their substation switching arrangement; topology="node_breaker" keeps it instead of merging it away at import, so a breaker becomes an element with an identity, a flow and a position you can change.

model = gridoxide.PowerFlowModel.from_cgmes(
    paths, topology="node_breaker", retain="busbar_adjacent"
)
model.solve()
for switch_id, mrid, kind, bus_from, bus_to, is_open, branch in model.switches():
    print(f"{kind} {mrid}: buses {bus_from}-{bus_to}, {'open' if is_open else 'closed'}")

model.set_switch(switch_id, open=True)   # no re-import
model.solve()
  • topology is "bus_branch" (default, every switch merged — gridoxide's historical behaviour) or "node_breaker".
  • retain selects which switches survive as elements: "none" (merge them all, but still derive buses from ConnectivityNode), "busbar_adjacent" (the bus-breaker view) or "all" (the full node-breaker view). It is rejected with topology="bus_branch", where it would mean nothing.
  • model.switches() — one tuple per retained switch, identified by its CGMES mRID. is_open is the current position, so it follows set_switch.
  • model.set_switch(switch_id, open) — flips a breaker and rebuilds the admittance matrix.
  • model.switch_flow_p() — active power through each switch after a solve(), in switches() order.

Retention is what keeps this affordable. On SmallGrid, "none" gives 167 buses and "all" gives 1,369 — roughly a 16x larger system answering the same question — so keep only the switches you intend to operate. See DC (Bθ) Power Flow for what a switch costs once retained.

A switch is an ordinary branch to everything else, which is the point of representing it this way. Its branch index works with lodf_column, outage_flows and solve_contingencies with no new machinery — a bus-split contingency is just a branch outage:

for *_, branch in model.switches():
    if not model.is_radial(branch):
        redistribution = model.lodf_column(branch)   # who picks up its flow

AC contingency screening

For the full nonlinear answer rather than the DC approximation:

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", backend="klu_native")
results = model.solve_contingencies([[b] for b in range(model.n_branches)])
for branch, (status, vm, va) in enumerate(results):
    if status != "converged" or min(vm) < 0.9:
        print(f"outage of {branch}: {status}, min |V| = {min(vm):.3f}")
  • model.solve_contingencies(contingencies, threads=None) — one entry per scenario, each a list of flat branch indices to take out. Returns (status, voltage_mag, voltage_ang) per scenario, in order. status is "converged", "max_iterations" or "singular"; a contingency that leaves an unsolvable network is a screening result, so it does not raise.

The symbolic factorization is shared across every contingency that leaves the network connected — 2.0–2.7x against independent solves before any parallelism. Contingencies that sever the network fall back to a full rebuild, and their orphaned buses come back pinned to zero rather than as a spurious singular solve.

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", method="dc")
model.solve()
for branch in range(model.n_branches):
    after = model.outage_flows(branch)        # None if the branch is radial
    if after and max(map(abs, after)) > limit:
        print(f"outage of {branch} overloads the network")

There is deliberately no dense-matrix accessor here: a full PTDF on case9241pegase is 1.19 GB and a full LODF 2.06 GB. The Rust API offers them (ptdf_dense/lodf_dense) with those numbers in their doc comments.

Reusing factorization across repeated solves

PowerFlowModel wraps the Rust solver::PersistentSolver — it is that API, not a reimplementation of it — so repeated .solve() calls on one model reuse the cached symbolic factorization exactly as described in Backends and Factorization Reuse. Construct one model per topology, then solve as many times as needed:

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", backend="scalar")
for scenario in scenarios:
    apply_scenario(model, scenario)  # changes p/q values only
    model.solve()
    results.append(model.voltage_mag())

Call model.reset() if the topology itself changes between solves, not just bus values.

This is what lets the benchmark suite run its whole comparison in pure Python (scripts/bench/bench_gridoxide_native.py), timing gridoxide with the same time.perf_counter()-around-a-persistent-solve-object methodology every other tool there already uses (PGM's PowerGridModel, lightsim2grid's GridModel, pandapower's net), rather than shelling out to a compiled Rust binary and parsing its stdout.

Backends available from Python

BackendNotes
"klu_native" (default)From-scratch Rust translation of SuiteSparse KLU, always available in the wheel; about six times faster than "scalar" on large networks.
"scalar"Sparse LU via faer, no special build requirements.
"block"Block-structured variant (one 2×2 block per bus); faster on some topologies.

Two further backends exist in the source tree but are not in the published wheel, since they need extra system dependencies at build time — build from source with the matching Cargo feature:

  • "klu" — links vendored SuiteSparse C directly (--features python,klu).
  • "pardiso" — Intel oneMKL's PARDISO solver (--features python,pardiso, needs MKLROOT set).

See Backends and Factorization Reuse for what each one actually does and how they compare.

Building input grids

Two helpers produce or convert PGM JSON, so a working grid doesn't require hand-writing one. Both ship in the pip package itself (they used to live only under scripts/bench/) and are installed as console scripts:

  • gridoxide.generate_grid — synthetic radial MV/LV distribution grid generator at any scale, pure stdlib, no extra dependencies:

    from gridoxide.generate_grid import generate
    generate(target_nodes=2200, seed=42, out_path="grid.json")  # ~2,600 nodes
    

    or gridoxide-generate-grid grid.json --target-nodes 2200 --seed 42.

  • gridoxide.matpower (needs pip install gridoxide[matpower]) — converts a raw MATPOWER .m/.mat case into PGM JSON:

    from gridoxide.matpower import convert
    convert("case14.m", "case14.json")
    

    or gridoxide-matpower case14.m case14.json.

scripts/bench/generate_grid.py and matpower_to_pgm.py are thin CLI wrappers delegating to these same package modules, so the conversion logic only lives in one place.

There is deliberately no pandapower-based converter: it would pull in the full pandapower + power-grid-model-io dependency chain, and gridoxide.matpower already covers the same real-world test-case grids straight from their original MATPOWER sources. If you already have a pandapower.pandapowerNet and pandapower installed, scripts/bench/convert_pandapower_case.py in the main repo is a standalone (not packaged) converter.

How the extension is built

src/python.rs exposes PersistentSolver and PGM-JSON loading as gridoxide._gridoxide, a private compiled extension module built with maturin:

maturin develop --release --features python,klu

It is gated entirely behind the opt-in python Cargo feature and compiled by nothing else, so a plain cargo build/cargo test never touches it — the feature must never be combined with a plain cargo invocation.

This is a mixed Rust/Python maturin project (pyproject.toml's python-source = "python" plus module-name = "gridoxide._gridoxide"): pure-Python code lives in python/gridoxide/ and ships in the same wheel as the compiled extension, re-exported through python/gridoxide/__init__.py so callers only ever write import gridoxide.

python/tests/ holds a pytest suite (scalar/block only — no klu, matching what's published) checked against this project's own committed PGM reference fixtures, run by .github/workflows/python.yml on every push/PR. .github/workflows/pypi.yml builds wheels (Linux/Windows/macOS) plus an sdist and publishes to PyPI via trusted publishing on v* tags. The published wheel deliberately omits the klu backend — LGPL-2.1-or-later vendored SuiteSparse source, plus a C compiler and libclang needed on every target platform. See Provenance and Licensing.

C and C++ API

gridoxide exposes a C ABI so a C++ project can embed it. Enable the capi feature; the artifacts are a generated include/gridoxide.h, a hand-written header-only C++ wrapper include/gridoxide.hpp, and libgridoxide.a / libgridoxide.so.

#include "gridoxide.hpp"

auto options = gridoxide::PowerFlow::default_options();
options.s_base_va = 1e8;

auto pf = gridoxide::PowerFlow::from_pgm_file("network.json", options);
pf.solve();

const auto vm = pf.voltage_magnitude();
const auto [p, q] = pf.branch_flow();

Unlike klu, pardiso, opf-highs and opf-ipopt, this feature needs nothing installed — it is pure Rust plus a build-time crate — so it is built and tested in CI like opf.

The mirror image of everything else here

Four of gridoxide's features consume a C header through bindgen so Rust can call a library. This one runs cbindgen over src/capi/ so a library can call us. Same shape in build.rs, opposite direction.

Scope

Power flow. AC Newton-Raphson, the two outer loops (Q limits and distributed slack), voltages, branch flows, convergence detail and per-island status.

Not DC, sensitivity, state estimation, short circuit or OPF — all reachable from Python and from Rust. The ABI is shaped so they can be added without breaking it: each would be another opaque handle plus count-then-fill accessors.

One capability here has no Python equivalent: branch_flow() returns AC branch flows. The Python binding's branch_flow_p() is DC-only, so a caller who has just solved an AC power flow has to reimplement the π-model and its tap handling to get them. That seemed the wrong thing to mirror.

Getting data in

Two ways, both first-class.

A power-grid-model document, by path or from memory:

auto pf = gridoxide::PowerFlow::from_pgm_file("network.json", options);
auto pf = gridoxide::PowerFlow::from_pgm_string(json_text, options);

Arrays you already hold, in per-unit:

std::vector<gridoxide_bus> buses = { /* ... */ };
std::vector<gridoxide_line> lines = { /* ... */ };
auto pf = gridoxide::PowerFlow::from_arrays(buses, lines);

The structs are plain C layout — no pointers, nothing owned — so a std::vector of them passes straight through with no marshalling step. The C++ wrapper deliberately does not mirror them in C++; wrapping a POD struct in another POD struct would be a copy for nothing.

ZIP loads cannot cross the array path. gridoxide_bus has no place for voltage-dependent load terms, which are a variable-length list. A network with them must come in as a document, where they are read as a matter of course. Passed through the array path they are silently absent, and the solve converges perfectly well to the wrong answer.

Two things that will bite you otherwise

A document yields more buses than it has nodes

Converting a power-grid-model document appends a virtual slack bus per source, joined to the physical bus by an impedance branch. That is right for a power flow, where a source's output is an unknown rather than a schedule — but it means case14_ieee reports 15 buses, not 14.

Index results by what bus_count() reports, never by your own numbering.

Handles are single-thread-affine

A handle owns the cached symbolic factorization, and under the klu or pardiso features that is a raw pointer into SuiteSparse or MKL state which is not safe for concurrent use. Python encodes this as #[pyclass(unsendable)]; C has no equivalent, so it is a documented rule instead. gridoxide::PowerFlow is movable but not copyable, which stops the obvious accident.

Separate handles on separate threads are fine.

Errors

Every fallible C function returns a gridoxide_status; the message is fetched separately with gridoxide_last_error_message(), valid until the next call on that thread. The C++ wrapper turns both into a gridoxide::Error carrying the code and the message.

The codes split the way the Python binding's exceptions do — caller-fault versus numerics — plus one the Python binding did not need:

CodeMeaning
GRIDOXIDE_STATUS_OKSucceeded.
..._INVALID_ARGUMENTYour mistake: null pointer, bad index, wrong buffer length, unavailable backend.
..._IO / ..._PARSEThe file could not be read / the document could not be parsed.
..._NOT_CONVERGEDRan out of iterations. The state is still readable.
..._SINGULARThe Jacobian was singular.
..._NO_ANSWERThe question does not apply here. Not a failure.
..._PANICA panic was caught at the boundary — always a gridoxide bug.

NO_ANSWER is the third channel. The Rust API uses Option::None to mean "no such answer exists" — asking for the slack shift after a solve that did not distribute slack, for instance. Folding that into the error codes would force a caller to parse message strings to tell "your input was wrong" from "the question does not apply". The C++ wrapper returns an empty vector rather than throwing.

Panics are caught, not fatal

A panic! unwinding out of an extern "C" function aborts the process — it would take your program down from inside a library it merely linked. Every exported function runs its body inside catch_unwind and reports GRIDOXIDE_STATUS_PANIC instead.

That is not hypothetical. outerloop::DistributedSlack asserts on a wrong-length participation vector, YBus::finish expects well-formed triplets, and the document conversion unwraps in places. The ABI checks what it can up front and reports INVALID_ARGUMENT — a far more useful answer — and the guard is the backstop for the panics nobody predicted.

Memory

Nothing allocated on one side is freed by the other. Results go into caller-owned buffers whose length you learn from a _count call first:

size_t n = gridoxide_powerflow_bus_count(pf);
double *vm = malloc(n * sizeof(double));
gridoxide_powerflow_voltage_magnitude(pf, vm, n);

That removes allocator-mismatch bugs entirely, and costs nothing since every result is a flat array indexed by bus or by branch. The C++ wrapper does the count-then-fill once so you get a std::vector.

The handle is the exception: opaque, released by gridoxide_powerflow_free, or by the C++ destructor.

Buffer lengths are checked, not trusted — passing a bus-sized buffer to a per-branch accessor returns INVALID_ARGUMENT rather than overrunning it.

Building

With CMake

# From source: cargo runs as part of the build. Always in step; needs Rust.
cmake -S capi -B build/capi -DGRIDOXIDE_FROM_SOURCE=ON
cmake --build build/capi

# Prebuilt: no Rust toolchain anywhere.
cmake -S capi -B build/capi \
      -DGRIDOXIDE_LIBRARY=/path/to/libgridoxide.a \
      -DGRIDOXIDE_INCLUDE_DIR=/path/to/include
cmake --build build/capi

Either way you get one imported target, so the consuming CMakeLists.txt does not care which was used:

find_package(gridoxide REQUIRED)
target_link_libraries(my_app PRIVATE gridoxide::gridoxide)

A Rust staticlib carries no runtime of its own, so the target also brings the platform link dependencies (pthread, dl, m on Linux). Omitting those is the usual first failure — it appears as pages of undefined std symbols.

With cargo directly

cargo build --release --features capi

produces target/release/libgridoxide.{a,so} and regenerates include/gridoxide.h.

Never combine capi with python. pyo3's extension-module leaves libpython deliberately unlinked — right for something Python dlopens, wrong for a library a C++ binary links directly. The two cannot come from one cargo invocation. The same trap the python feature already documents for tests and examples.

The header is generated and committed

include/gridoxide.h is cbindgen's output and is committed, so a consumer of a prebuilt release needs no Rust toolchain to get a header. Nothing then forces it to match the code, so tests/capi_test.rs fails if it has drifted — the same guard test_committed_documents_match_a_fresh_conversion provides for the committed OPF fixtures.

That guard earned its keep immediately. Two accessors were originally written as a macro_rules!, which is exactly what a macro is for — two near-identical functions. But cbindgen does not expand macros, so they existed in the library and were absent from the header: a consumer would have met symbols that link but do not declare. They are written out longhand now, and the test names any exported function the header does not declare.

GRIDOXIDE_ABI_VERSION is injected into the header from the Rust constant rather than written twice, so the two cannot disagree. gridoxide::check_abi_compatibility() compares them at run time — worth calling once at startup when linking a prebuilt library, pointless when building from source.

How it is tested

tests/capi_test.rs drives the exported functions through raw pointers exactly as C++ would, but needs no C compiler, so it runs wherever cargo test does. The strongest check there is that the ABI reproduces a direct Rust solve bit-for-bit — not to a tolerance, since both paths run the same iteration on the same matrix, so any difference at all is a marshalling error rather than numerical weather. The array path is checked the same way against the document path, which is what makes the #[repr(C)] layouts trustworthy.

capi/examples/powerflow.c and .cpp cover what only a real compiler can: that the header parses as C11 and C++17, that the struct layouts agree, and that the symbols link. CI compiles and runs both.

MCP Server

gridmcp is a Model Context Protocol server: it lets an LLM chat — Claude Code, Claude Desktop, VS Code's agent mode, Codex, Cursor — read the power networks in a directory and run gridoxide over them. Its source is crates/gridoxide-mcp.

It reads every format gridoxide solve reads: MATPOWER (.m), UCTE-DEF (.uct), PowSyBl IIDM (.xiidm) and directories of CGMES profiles. The loading and the solve are the same code — gridoxide::load — so the server and the CLI cannot disagree about a network.

Tools

Tool
list_networksthe networks below a directory, with their format
summarysize, bus types, voltage levels, tap changers, regulating controls, areas, and the importer's notes
power_flowan AC power flow, with the outer loops gridoxide solve offers as flags (control_taps, enforce_q_limits, distribute_slack, area_interchange, control_remote_voltage)
resultsthe buses or branches of the last power_flow on a network, filtered by name and sorted

Every tool reads; none writes a file. Output is plain text sized for a model's context: long lists stop at a limit and say how many rows are left.

Buses and branches carry the file's own names — MATPOWER bus numbers, UCTE node codes, IIDM ids, CGMES TopologicalNode and equipment mRIDs. MATPOWER names no branches, so they are from-to in bus numbers, with #k on the k-th parallel one. A bus's P and Q are its net injection, worked out from the solved voltages by Kirchhoff, so they are right for slack and PV buses too.

Install

VS Codethe gridoxide extension bundles the binary and offers it to chat; nothing else to install
uvuvx --from gridoxide-mcp gridmcp — no install step
pip / pipxpipx install gridoxide-mcp, then gridmcp
cargocargo install gridoxide-mcp

--dir <path> (else GRIDOXIDE_NETWORK_DIR, else the working directory) is where relative paths resolve and where list_networks looks.

Configure a client

Claude Code starts servers in the project directory, so --dir can be left out:

claude mcp add gridoxide -- uvx --from gridoxide-mcp gridmcp

Codex (~/.codex/config.toml):

[mcp_servers.gridoxide]
command = "uvx"
args = ["--from", "gridoxide-mcp", "gridmcp", "--dir", "/path/to/networks"]

Claude Desktop, Cursor, Windsurf and most others take an mcpServers block:

{
  "mcpServers": {
    "gridoxide": {
      "command": "uvx",
      "args": ["--from", "gridoxide-mcp", "gridmcp", "--dir", "/path/to/networks"]
    }
  }
}

Trying it by hand

The transport is one JSON-RPC message per line on stdin and stdout:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"power_flow","arguments":{"path":"tests/data/benchmark-grids/matpower/case14.m"}}}' \
  | cargo run -q -p gridoxide-mcp -- --dir .

The Power Flow Problem

The powerflow problem is about the calculation of voltage magnitudes and angles for all network nodes. The solution is obtained from a subset of voltages and power injections.

Power System Model

Power systems are modeled as a network of nodes (buses) and branches (lines and transformers). Power sources (generators) and sinks (loads) can be connected to the nodes. Each node in the network is fully described by the following four electrical quantities:

  • \(\vert V_k \vert\): voltage magnitude
  • \(\theta_k\): voltage phase angle
  • \(P_k\): active power
  • \(Q_k\): reactive power

There are three types of network nodes: VD (also called the slack or reference bus — types::BusType::Slack in gridoxide's own code), PV and PQ. Depending on the node type, two of the four electrical quantities are specified.

Node TypeKnownUnknown
\(VD\)\(\vert V_k \vert, \theta_k\)\(P_k, Q_k\)
\(PV\)\(P_k, \vert V_k \vert\)\(Q_k, \theta_k\)
\(PQ\)\(P_k, Q_k\)\(\vert V_k \vert, \theta_k\)

Newton Raphson

The goal is to bring a nonlinear mismatch function \(f\) to zero. The value of the mismatch function depends on a solution vector \(x\):

\[ f(x) = 0 \]

As \(f(x)\) is nonlinear, the equation system is solved iteratively using Newton-Raphson:

\[ x_{i+1} = x_i + \Delta x_i = x_i - \textbf{J}_f(x_i)^{-1} f(x_i) \]

where \(\Delta x\) is the correction of the solution vector and \(\textbf{J}_f\) is the Jacobian matrix.

Instead of computing \(\Delta x_i = - \textbf{J}_f(x_i)^{-1} f(x_i)\), the linear equation set

\[ - \textbf{J}_f(x_i) \Delta x_i = f(x_i) \]

is solved for \(\Delta x_i\).

Iterations are stopped when the mismatch is sufficiently small:

\[ f(x_i) < \epsilon \]

Powerflow Solution

The solution vector \(x\) represents the voltage \(V\) either in polar coordinates

\[ \left [ \begin{array}{c} \delta \ \vert V \vert \end{array} \right ] \]

or rectangular coordinates

\[ \left [ \begin{array}{c} V_{real} \ V_{imag} \end{array} \right ] \]

The mismatch function \(f\) represents the power mismatch

\[ \Delta S = \left [ \begin{array}{c} \Delta P \ \Delta Q \end{array} \right ] \]

or the current mismatch

\[ \Delta I = \left [ \begin{array}{c} \Delta I_{real} \ \Delta I_{imag} \end{array} \right ] \]

This results in four different formulations of the powerflow problem:

  • power mismatch function and polar coordinates
  • power mismatch function and rectangular coordinates
  • current mismatch function and polar coordinates
  • current mismatch function and rectangular coordinates

To solve the problem using Newton-Raphson, we need to formulate \(\textbf{J}_f\) and \(f\) for each powerflow problem formulation.

Powerflow with Power Mismatch Function and Polar Coordinates

The injected power at a node \(k\) is given by

\[ S_k = V_k I_k^* \]

The current injection into any node \(k\) is

\[ I_k = \sum_{j=1}^N Y_{kj} V_j \]

Substitution yields

\[ \begin{align*} S_k &= V_k \left ( \sum_{j=1}^N Y_{kj} V_j \right )^* \\ &= V_k \sum_{j=1}^N Y_{kj}^* V_j^* \end{align*} \]

\(G_{kj}\) and \(B_{kj}\) are defined as the real and imaginary part of the admittance matrix element \(Y_{kj}\), so that \(Y_{kj} = G_{kj} + jB_{kj}\). This results in

\[ \begin{align*} S_k &= V_k \sum_{j=1}^N Y_{kj}^* V_j^* \\ &= \vert V_k \vert \angle \theta_k \sum_{j=1}^N (G_{kj} + jB_{kj})^* ( \vert V_j \vert \angle \theta_j)^* \\ &= \vert V_k \vert \angle \theta_k \sum_{j=1}^N (G_{kj} - jB_{kj}) ( \vert V_j \vert \angle - \theta_j) \\ &= \sum_{j=1}^N \left \vert V_k \vert \vert V_j \vert \angle (\theta_k - \theta_j) \right (G_{kj} - jB_{kj}) \\ &= \sum_{j=1}^N \vert V_k \vert \vert V_j \vert \left ( cos(\theta_k - \theta_j) + jsin(\theta_k - \theta_j) \right ) (G_{kj} - jB_{kj}) \end{align*} \]

If we perform the algebraic multiplication of the two terms inside the parentheses, and collect real and imaginary parts, and recall that \(S_k = P_k + jQ_k\), we can split this into two equations: one for the real part, and one for the imaginary part.

\[ \theta_{kj} = \theta_k - \theta_j \\ P_k = \sum_{j=1}^N \vert V_k \vert \vert V_j \vert \left ( G_{kj}cos(\theta_{kj}) + B_{kj} sin(\theta_{kj}) \right ) \\ Q_k = \sum_{j=1}^N \vert V_k \vert \vert V_j \vert \left ( G_{kj}sin(\theta_{kj}) - B_{kj} cos(\theta_{kj}) \right ) \]

These are called the power flow equations.

We consider a power system network having \(N\) buses. We assume one VD bus, \(N_{PV}-1\) PV buses and \(N-N_{PV}\) PQ buses. We assume that the VD bus is numbered bus \(1\), the PV buses are numbered \(2,...,N_{PV}\), and the PQ buses are numbered \(N_{PV}+1,...,N\). We define the vector of unknowns as the composite vector of unknown angles \(\theta\) and voltage magnitudes \(\vert V \vert\):

\[ x = \left[ \begin{array}{c} \theta \\ \vert V \vert \\ \end{array} \right ] = \left[ \begin{array}{c} \theta_2 \\ \theta_{3} \\ \vdots \\ \theta_N \\ \vert V_{N_{PV+1}} \vert \\ \vert V_{N_{PV+2}} \vert \\ \vdots \\ \vert V_N \vert \end{array} \right] \]

The right-hand sides of the powerflow equations for \(P_k\) and \(Q_k\) depend on the elements of the unknown vector \(x\).

Expressing this dependency more explicitly, we rewrite these equations as

\[ \begin{align*} P_k^{spec} = P_k^{calc} (x) \Rightarrow P_k^{calc} (x) - P_k^{spec} &= 0 \quad \quad k = 2,...,N \\ Q_k^{spec} = Q_k^{calc} (x) \Rightarrow Q_k^{calc} (x) - Q_k^{spec} &= 0 \quad \quad k = N_{PV}+1,...,N \end{align*} \]

We define the mismatch \({f} (x)\) as

\[ \begin{align*} f(x) = \left [ \begin{array}{c} f_1(x) \\ \vdots \\ f_{N-1}(x) \\ ------ \\ f_N(x) \\ \vdots \\ f_{2N-N_{PV} -1}(x) \end{array} \right ] = \left [ \begin{array}{c} P_2(x) - P_2 \\ \vdots \\ P_N(x) - P_N \\ --------- \\ Q_{N_{PV}+1}(x) - Q_{N_{PV}+1} \\ \vdots \\ Q_N(x) - Q_N \end{array} \right] = \left [ \begin{array}{c} \Delta P_2 \\ \vdots \\ \Delta P_N \\ ------ \\ \Delta Q_{N_{PV}+1} \\ \vdots \\ \Delta Q_N \end{array} \right ] = 0 \end{align*} \]

That is a system of nonlinear equations. The nonlinearity stems from the fact that \(P_k\) and \(Q_k\) have terms containing products of unknowns and also terms containing trigonometric functions of unknowns.

The Jacobian matrix is obtained by taking all first-order partial derivates of the power mismatch function with respect to the voltage angles \(\theta_k\) and magnitudes \(\vert V_k \vert\):

\[ \theta_{jk} = \theta_j - \theta_k \\ \begin{align*} J_{jk}^{P \theta} &= \frac{\partial P_j (x ) } {\partial \theta_k} = \vert V_j \vert \vert V_k \vert \left ( G_{jk} sin(\theta_{jk}) - B_{jk} cos(\theta_{jk} ) \right ) \\ J_{jj}^{P \theta} &= \frac{\partial P_j(x)}{\partial \theta_j} = -Q_j (x ) - B_{jj} \vert V_j \vert ^{2} \\ J_{jk}^{Q \theta} &= \frac{\partial Q_j(x)}{\partial \theta_k} = - \vert V_j \vert \vert V_k \vert \left ( G_{jk} cos(\theta_{jk}) + B_{jk} sin(\theta_{jk}) \right ) \\ J_{jj}^{Q \theta} &= \frac{\partial Q_j(x)}{\partial \theta_j} = P_j (x ) - G_{jj} \vert V_j \vert ^{2} \\ J_{jk}^{PV} &= \frac{\partial P_j (x ) } {\partial \vert V_k \vert } = \vert V_j \vert \left ( G_{jk} cos(\theta_{jk}) + B_{jk} sin(\theta_{jk}) \right ) \\ J_{jj}^{PV} &= \frac{\partial P_j(x)}{\partial \vert V_j \vert } = \frac{P_j (x )}{\vert V_j \vert} + G_{jj} \vert V_j \vert \\ J_{jk}^{QV} &= \frac{\partial Q_j (x ) } {\partial \vert V_k \vert } = \vert V_j \vert \left ( G_{jk} sin(\theta_{jk}) - B_{jk} cos(\theta_{jk}) \right ) \\ J_{jj}^{QV} &= \frac{\partial Q_j(x)}{\partial \vert V_j \vert } = \frac{Q_j (x )}{\vert V_j \vert} - B_{jj} \vert V_j \vert \\ \end{align*} \]

The linear system of equations that is solved in every Newton iteration can be written in matrix form as follows

\[ \begin{align*} -J(x) \left [ \begin{array}{c} \Delta \theta \\ \Delta \vert V \vert \end{array} \right ] &= \left [ \begin{array}{c} \Delta P \\ \Delta Q \end{array} \right ] \\ \Rightarrow J(x) \left [ \begin{array}{c} \Delta \theta \\ \Delta \vert V \vert \end{array} \right ] &= \left [ \begin{array}{c} -\Delta P \\ -\Delta Q \end{array} \right ] \end{align*} \]

\[ \begin{align*} \left [ \begin{array}{cccccc} \frac{\partial \Delta P_2 }{\partial \theta_2} & \cdots & \frac{\partial \Delta P_2 }{\partial \theta_N} & \frac{\partial \Delta P_2 }{\partial \vert V_{N_{G+1}} \vert} & \cdots & \frac{\partial \Delta P_2 }{\partial \vert V_N \vert} \\ \vdots & \ddots & \vdots & \vdots & \ddots & \vdots \\ \frac{\partial \Delta P_N }{\partial \theta_2} & \cdots & \frac{\partial \Delta P_N}{\partial \theta_N} & \frac{\partial \Delta P_N}{\partial \vert V_{N_{G+1}} \vert } & \cdots & \frac{\partial \Delta P_N}{\partial \vert V_N \vert} \\ \frac{\partial \Delta Q_{N_{G+1}} }{\partial \theta_2} & \cdots & \frac{\partial \Delta Q_{N_{G+1}} }{\partial \theta_N} & \frac{\partial \Delta Q_{N_{G+1}} }{\partial \vert V_{N_{G+1}} \vert } & \cdots & \frac{\partial \Delta Q_{N_{G+1}} }{\partial \vert V_N \vert} \\ \vdots & \ddots & \vdots & \vdots & \ddots & \vdots \\ \frac{\partial \Delta Q_N}{\partial \theta_2} & \cdots & \frac{\partial \Delta Q_N}{\partial \theta_N} & \frac{\partial \Delta Q_N}{\partial \vert V_{N_{G+1}} \vert } & \cdots & \frac{\partial \Delta Q_N}{\partial \vert V_N \vert} \end{array} \right ] \left [ \begin{array}{c} \Delta \theta_2 \\ \vdots \\ \Delta \theta_N \\ \Delta \vert V_{N_{G+1}} \vert \\ \vdots \\ \Delta \vert V_N \vert \end{array} \right ] = \left [ \begin{array}{c} -\Delta P_2 \\ \vdots \\ -\Delta P_N \\ -\Delta Q_{N_{G+1}} \\ \vdots \\ -\Delta Q_N \end{array} \right ] \end{align*} \]

Solution Steps

  1. Set the iteration counter to \(i=1\). Use the initial solution \(V_{i} = 1 \angle 0^{\circ}\)
  2. Compute the mismatch vector \(f({x_i})\) using the power flow equations
  3. Check the stopping criterion
    • If \(\vert \Delta P_{i} \vert < \epsilon_{P}\) for all type PQ and PV buses and
    • If \(\vert \Delta Q_{i} \vert < \epsilon_{Q}\) for all type PQ
    • Then go to step 6
    • Else, go to step 4
  4. Evaluate the Jacobian matrix \(\textbf{J}_f(x_i)\) and compute \(\Delta x_i\).
  5. Compute the new solution vector \(x_{i+1}\) and return to step 3.
  6. Stop.

DC (Bθ) Power Flow

Motivation

Newton-Raphson answers the full question — every voltage magnitude, every angle, every reactive flow — and pays for it with an initial guess, an iteration count, and the possibility of not converging at all. A great many operational questions do not need that. How does this transfer divide between the two corridors? Which line does the outage overload? Is this dispatch feasible on the wires? Those are questions about active power, and active power divides between paths in a way that barely depends on voltage magnitude at all.

The DC approximation makes three assumptions and gets a linear problem out:

  • every voltage magnitude is exactly 1 p.u.,
  • every branch resistance is zero,
  • every angle difference is small, so \(\sin\delta \approx \delta\) and \(\cos\delta \approx 1\).

What remains is one sparse symmetric system, solved once by direct factorization. There is no initial guess, no iteration count, no convergence to fail — a DC power flow either has a reference bus or it does not. That unconditional robustness is why DC survives as a first-class mode in every serious tool despite being, on its face, a crude approximation: it is the method you can run a hundred thousand times inside a contingency screen and trust to come back.

What it gives up is everything reactive. No voltage magnitudes, no Q flows, no losses, no way to tell a PV bus from a PQ bus.

What changes in the equation system

Starting from the AC branch flow at \(|V| = 1\), with \(\delta = \theta_f - \theta_t - \alpha\) and a transformer ratio \(k\), the three assumptions collapse the flow to a linear function of angle:

\[ P_{from} = b,(\theta_{from} - \theta_{to} - \alpha), \qquad P_{to} = -P_{from} \]

Two decisions remain, and the tools genuinely disagree on both.

1. Which susceptance?

\[ \text{ignore } r:\quad b = \frac{1}{x} \qquad\qquad \text{ignore } g:\quad b = \frac{x}{r^2 + x^2} = -\operatorname{Im}(y_{series}) \]

The first is the textbook form and what MATPOWER, pandapower and lightsim2grid all compute. The second is strictly the better linearization — it is the exact coefficient of \(\sin\delta\) in the AC flow equation, whereas \(1/x\) is that coefficient only in the limit \(r \to 0\). On transmission the two differ by \(1/(1 + (r/x)^2)\), about 1% at \(r/x = 0.1\); on a distribution feeder where \(r/x\) exceeds 1, they differ by more than half.

There is a neat consequence worth knowing: where \(r/x\) is uniform across the network, the two choices differ by a constant factor on every branch, which rescales every angle and leaves every flow identical. The choice can only change an answer on a network whose \(r/x\) varies from branch to branch.

2. Where does the phase shift go?

A phase-shifting transformer contributes a constant \(-b\alpha\) to its branch flow. Being constant, it moves to the right-hand side rather than into the matrix, which is what keeps \(B\) symmetric even on a network full of phase shifters:

\[ B,\theta = P + \varphi, \qquad \varphi_i = \sum_{from_k = i} b_k \alpha_k ;-; \sum_{to_k = i} b_k \alpha_k \]

MATPOWER stores \(-\varphi\) and calls it Pbusinj. The symmetry this preserves is not merely tidy — the sensitivity factors below solve against \(B^\mathsf{T}\) without ever forming it.

3. Which reference?

DC fixes angles, not voltages, so a component needs exactly one angle reference to be determined — but unlike AC, two references do not over-determine it. Each simply supplies whatever its own incident branches demand. The system stays well posed; only the split of slack pickup between the references becomes arbitrary.

Where this fits in gridoxide today

The solver is linear::btheta::dc_power_flow, reached through PowerFlowMethod::Dc. It runs in five steps:

  1. dc_branches reduces every Line and Transformer to a DcBranch — a susceptance, a phase shift, and the crate's flat branch index (lines first, then transformers, the same index space branch_flow::branch_params uses). Branches with no series path — half-open self-loop lines, transformers with an open terminal — produce nothing and are listed in DcSolution::ignored_branches.
  2. dc_components partitions the buses with topology::UnionFind. This is deliberately not network::connected_components, which needs a &YBusSparse that a branch-list solver has no reason to build.
  3. network::classify and network::mark_unreferenced_islands — reused unchanged from the AC path, so a sourceless island is pinned to a placeholder rather than given a fabricated reference, exactly as documented under Multi-Island Power Flow.
  4. One factorization per island, through sparse::RealFactorization. Per-island rather than one shared system because the LU of a block-diagonal matrix is the sum of the block LUs — it costs nothing, and it means a singular island can be named rather than merely suspected.
  5. Flows, pickup and residual are derived from the solved angles. DcSolution carries per-branch active flows, per-island slack pickup, and a max_residual that is a factorization-quality check rather than a convergence check — there is nothing to converge.

Two details of gridoxide's own data model needed explicit handling.

Purely resistive branches. IgnoreR asserts \(r = 0\), so a branch with \(x = 0\) has no impedance left at all in that approximation's world — it is a short circuit, and gets the crate's standard zero-impedance treatment (topology::clamp_branch_impedance) rather than an infinite susceptance. This is not hypothetical: power-grid-model's link/dummy-test fixture contains a line at \(r = 10,\Omega,\ x = 0\), and dropping it would silently split that network into a live island and a sourceless one. Under IgnoreG the same branch correctly comes out at \(b = 0\), because a purely resistive branch really does transmit no angle-driven active power — so that network genuinely islands under IgnoreG. The two approximations disagree about such a branch by construction, not by accident.

PGM links. A link carries topology::IDEAL_CONNECTION_Y = 2e5 + j2e5, whose positive imaginary part is inherited from power-grid-model's own 1e8 + j1e8 and is a regularization choice, not a claim that the element is capacitive. Inverting it gives \(x = -2.5\times10^{-6}\), and a negative susceptance would flip that branch's coupling sign and make \(B\) indefinite. AC never noticed, because only \(|y|\) and the link's own reported Q depend on that sign. dc_branches matches on the constant — never on \(x < 0\) in general, since a genuinely negative reactance is a series capacitor and must pass through with its sign intact.

Scope: what DC here deliberately does not do

Shunt admittance is ignored entirely, both \(b\) and \(g\), matching powsybl-open-loadflow (whose DcEquationSystemCreator has no shunt term). MATPOWER and pandapower additionally fold \(G_s\) in as a constant real load at \(|V| = 1\); gridoxide does not, so on data where \(g_{shunt} \neq 0\) its slack pickup differs from theirs by exactly \(\sum g_{ii}\). gridoxide has three separate shunt sources (Line::g_shunt, Transformer::y_shunt, and the network::ShuntAdm list the DC entry point is not even handed); covering one and not the others would be worse than covering none.

Bus type is ignored beyond Slack in the solve: PV and PQ buses are both unknowns, because the distinction between them is entirely about reactive power. It is not ignored on write-back, though. A PV bus's voltage_mag is a setpoint — an input the AC solver holds fixed and never updates — so DC leaves it alone, along with the slack's, and normalizes only PQ magnitudes to its own \(|V| = 1\) assumption. Overwriting a setpoint would silently change the result of any AC solve seeded from that state.

Distributed slack is not supported, here or in the sensitivity factors. Each island has one reference, or several whose pickup split is arbitrary.

Validated against real data

tests/dc_powerflow_test.rs. The load-bearing test is dc_flows_match_the_lossless_ac_limit, which checks DC against gridoxide's own AC branch-flow code (branch_flow::terminal_flow, built on network::branch_calc_param) on a lossless, meshed network carrying both an off-nominal transformer and a phase shifter. That is a genuinely independent path — complex π-model arithmetic against real Bθ assembly — and the two agree to \(10^{-6}\), the cubic term the linearization drops. A flipped \(\varphi\) sign or a \(1/k^2\) where \(1/k\) belongs fails it by several orders of magnitude.

Alongside it: exact power conservation on symmetric/transmission-case (\(10^{-9}\)), the link-sign regression on link/dummy-test, and the flat-branch-index contract against branch_flow::branch_params.

dc_angle_error_tracks_the_voltage_assumption_it_rests_on is worth reading for what it measures. On symmetric/transmission-case the worst DC-versus-AC angle gap is 0.041 rad — about half the network's entire 0.087 rad angle span. That is not an implementation error: this fixture's AC voltages run to 1.19 p.u., and since flow goes as \(V_i V_j b \sin\delta\), a bus at 1.19 p.u. carries its power at a visibly smaller angle than DC predicts. Restricted to the buses whose AC voltage actually lands near 1 p.u. — where DC's own assumption holds — the same comparison comes in at \(2.6\times10^{-3}\) rad, two orders better. DC's accuracy is exactly as good as the assumption it rests on, and this test measures both sides of that.

Sensitivity factors: PTDF and LODF

Because DC is exactly linear, the derivative of a branch flow with respect to a bus injection is not a local slope that drifts as the operating point moves — it is a constant, and the true global answer. That is what makes these factors worth precomputing, and it is the property AC sensitivities do not have.

gridoxide computes those too — see AC Sensitivity Analysis — but they are local derivatives, valid near the operating point they were taken at and requiring a fresh factorization whenever it moves. What they buy in exchange is everything DC discards: voltage magnitudes, reactive flows, losses. The two are complements rather than competitors, and the vocabulary below (a column per variable, a row per monitored branch) is deliberately shared between them.

PTDF answers if I inject one more unit at bus \(j\) and let the reference absorb it, how much shows up on branch \(k\)? LODF answers if branch \(l\) trips, what fraction of what it was carrying lands on branch \(k\)? — the workhorse of contingency screening, since it replaces a re-solve per outage with a matrix column.

\[ \mathrm{PTDF}[:, j] = B_f,\theta^{(j)}, \qquad B_{NN},\theta^{(j)} = e_j \] \[ \mathrm{PTDF}[k, :] = x, \qquad B_{NN},x = B_f[k, N]^\mathsf{T} \] \[ \mathrm{LODF}[:, l] = \frac{\mathrm{PTDF}[:, from_l] - \mathrm{PTDF}[:, to_l]}{1 - d_l}, \qquad d_l = \mathrm{PTDF}[l, from_l] - \mathrm{PTDF}[l, to_l] \]

Two economies fall out of the structure. A PTDF row is one solve rather than one per bus, because \(B_{NN}\) is symmetric — which is true only because the phase shift was kept out of the matrix. And a LODF column's numerator is one solve against \(e_{from} - e_{to}\), which yields \(d_l\) as a by-product, so a LODF column costs exactly what a PTDF column does.

linear::sensitivity::DcSensitivity factorizes each island's \(B\) once in new and every accessor is a solve against that — the reason sparse::RealFactorization exists rather than reusing the Newton path's solver::LinearSolver, whose contract refactorizes on every call and would pay for a factorization once per bus.

Radial branches have no LODF column. \(d_l \to 1\) exactly when branch \(l\) is a bridge: removing it disconnects the network, so its power has nowhere to redistribute to and no finite factor exists. lodf_column returns None and is_radial reports it — a structural fact, not a numerical accident, which is why it is not papered over with a large number.

Prefer columns and rows to matrices. A dense PTDF on case9241pegase is 1.19 GB and a dense LODF 2.06 GB. ptdf_dense/lodf_dense exist for small networks and for tests, and their doc comments carry those numbers.

Validated against real data

tests/dc_sensitivity_test.rs, against oracles that share no code with the sensitivity module. PTDF is checked by perturbing an injection and re-solving — exact to round-off, since DC is linear, so this is the derivative rather than an approximation of it. LODF is checked by actually opening the branch and re-solving, then confirming each surviving branch picked up the predicted fraction. Both hold to \(10^{-9}\).

Batching and contingencies

Two things fall out of DC being exactly linear, and they are what make it the method people actually run at scale.

A batch costs one factorization, not one per scenario. B depends only on topology, so a bus-injection scenario changes nothing but the right-hand side, and a scenario's answer is the base solve plus the response to that scenario's injection delta:

\[ \theta_s = \theta_{base} + \Delta\theta(\Delta P_s), \qquad P^{f}s = P^{f}{base} + \Delta P^{f}(\Delta P_s) \]

linear::batch::DcBatchSolver does exactly that, reusing DcSensitivity's per-island factorization. It is a stronger guarantee than batch::BatchSolver can make on the AC side, which reuses only the symbolic half — Newton's Jacobian changes numerically at every iteration of every scenario. Measured at 9x (case118) to 55x (case9241pegase) per scenario against independent solves, widening with size because the factorization is what gets hoisted (scripts/bench/README.md §8). It is not an approximation: tests/dc_batch_test.rs::batch_matches_a_sequential_loop_exactly checks it against a plain loop over dc_power_flow at \(10^{-12}\).

An outage needs no re-solve at all. DcSensitivity::outage_flows gives post-outage flows as \(f + \mathrm{LODF}[:, l]\cdot f_l\) — one triangular solve against the same factorization. This is why Scenario::branch_outages is rejected by the DC batch solver as it is by the AC one, but for the opposite reason: on AC it is unimplemented because an outage gives each scenario its own sparsity pattern; here it is unnecessary, and folding it into a scenario type built around per-bus overrides would be a worse API than the one that already exists. Radial branches return None, since removing one islands the network rather than rerouting anything.

Simultaneous outages need one more step, and it is not optional. DcSensitivity::multi_outage_flows handles a whole set at once. Removing k branches is a rank-k update to \(B\), so Woodbury reduces the entire problem to one \(k\times k\) solve on top of the same \(k\) triangular solves a set of single outages would need:

\[ (I - \Psi_{LL}),c = f_L, \qquad f^{new} = f + \sum_{l \in L} c_l,\psi[:, l] \]

where \(\psi[m][l]\) is the flow appearing on branch \(m\) per unit of power transferred across branch \(l\)'s terminals. At \(k = 1\) this collapses to \(c = f_l/(1 - d_l)\) and the familiar LODF column, which is why outage_flows simply delegates here rather than keeping a second implementation.

Applying single-branch LODF columns one after another is not a substitute. Each was computed on the intact network, so chaining ignores how the first outage changes the way the second one's power redistributes — algebraically, the sequential route drops the \(-\Psi_{ab}c_b\) coupling term, so the two agree only where \(\Psi_{ab} = 0\). chaining_single_outages_is_not_a_substitute measures that gap on a meshed five-bus network: the simultaneous answer matches a re-solve to \(10^{-9}\) while the chained one is out by more than \(10^{-3}\).

\(I - \Psi_{LL}\) is singular exactly when the set is breaking — removing all of it disconnects the network. is_breaking_set reports that, and it is the case single-branch screening misses entirely: two parallel lines are each individually non-radial, but together they may be the only path.

Tool reference

ToolSusceptancePhase shiftWhere
gridoxideboth, selectable (DcApproximation)RHS injectionlinear::btheta::dc_power_flow
lightsim2grid\(1/x\), precomputed per branchRHS injectionBaseDCAlgo.tpp; ydc_* in TwoSidesContainer_rxh_A.hpp
powsybl-open-loadflowboth (DcApproximationType)RHS injection, opposite \(\alpha\) sign conventiondc/equations/DcEquationSystemCreator
pandapower\(1/x\)Pbusinj (MATPOWER-derived)rundcpp, makeBdc
MATPOWER\(1/x\)Pbusinjdcpf, makeBdc
power-grid-model— (no Bθ mode; its linear method is the constant-admittance one)——

Note the last row. "DC / linear power flow" names two different algorithms across these tools, and power-grid-model's CalculationMethod.linear is not this one — see The Constant-Admittance Linearization.

The Constant-Admittance Linearization

Motivation

"Linear power flow" names two unrelated algorithms in this field, and the difference matters enough to be worth its own page.

The DC approximation discards resistance and voltage magnitude to get a real system in angles alone. That is the right trade on transmission, where flows divide by reactance and magnitudes barely move. It is the wrong trade on a distribution feeder, where the interesting quantity is the voltage drop along the feeder — precisely what DC throws away by construction.

The constant-admittance linearization makes a different sacrifice. It keeps the full complex network and keeps resistance; what it linearizes is the load model. A constant-power load is what makes power flow nonlinear — the current it draws depends on the voltage that the current itself determines. Replace each load with the fixed admittance that would draw its rated power at \(|V| = 1\), and the circuit becomes an ordinary linear one:

\[ S_i = U_i \overline{I_i} = -U_i\overline{U_i Y_{load}} ;\implies; Y_{load} = -\overline{S_i} \quad\text{at } |U| = 1 \]

Fold that into the Y-bus diagonal and one complex factorization gives every voltage, magnitude and angle alike.

What changes in the equation system

\[ Y',U = I, \qquad Y' = Y + \operatorname{diag}(-\overline{S}) \]

with fixed-voltage buses moved to the right-hand side via their known \(U\). Solved once. No iteration, no convergence criterion.

The approximation is exact when the loads really are constant-impedance — which is not a hypothetical, since gridoxide's ZipTerm/ZipKind model represents exactly that case — and degrades as loading moves away from nominal. It is at its best where DC is at its worst and vice versa:

DC (Bθ)Constant-admittance
Arithmeticrealcomplex
Keeps resistancenoyes
Produces \(|V|\)noyes
Produces Qnoyes
Exact whenangles are small and \(r \to 0\)loads are constant-impedance
Natural hometransmissiondistribution

Where this fits in gridoxide today

linear::impedance::linear_power_flow, reached through PowerFlowMethod::LinearImpedance.

gridoxide has had this algorithm since before it was a solver: it was written as Newton-Raphson's warm start, mirroring power-grid-model's own NewtonRaphsonPFSolver::initialize_derived_solver, and it remains that (network::linear_initial_guess). Both entry points share one implementation (linear::impedance::solve_constant_admittance); what the standalone mode adds is the island handling a warm start never needed — connected_components, classify and mark_unreferenced_islands, so a sourceless island is pinned rather than solved and a singular island is attributable to itself.

The two differ in one deliberate respect. linear_initial_guess runs before the solver's own classification pass, so it must not mutate bus types, and a singular result there is simply a warm start that did not happen rather than a failure to report.

This is mathematically power-grid-model's CalculationMethod.linear, down to the \(Y_{load} = -\overline{S}\) step (power_grid_model/math_solver/linear_pf_solver.hpp).

Scope: only PQ buses are unknowns

PV buses are held at their present voltage, exactly as Slack buses are. The method has no mechanism for "magnitude fixed, angle free" — that constraint is not linear in \(U\).

On power-grid-model data this costs nothing, because PGM has no PV buses at all: its sources are slacks. That is also why the comparison with PGM is exact. On data that does carry PV buses — CGMES imports, typically — treating them as fully fixed is a second approximation on top of the linearization, and it is the reason this mode is offered alongside Newton-Raphson rather than instead of it.

Validated against real data

In-module tests in crates/gridoxide-core/src/linear/impedance.rs. a_constant_impedance_load_is_reproduced_exactly checks the defining property: a load that genuinely is constant-impedance is solved exactly, so the solved voltages reproduce the specified power to round-off. agrees_with_the_warm_start_entry_point pins the promotion as a refactor rather than a reimplementation, asserting the standalone mode and network::linear_initial_guess agree to \(10^{-15}\) bus for bus.

Tool reference

ToolHas itWhere
gridoxideyeslinear::impedance::linear_power_flow
power-grid-modelyes, plus a linear_current variantCalculationMethod.linear, linear_pf_solver.hpp
VeraGridyes, as a distinct linearizationSolverType.LACPF
lightsim2gridno—
powsybl-open-loadflowno—
pandapowerno—

Outer Loops

Motivation

A Newton-Raphson solve answers what is the state, given these injections, these bus types and these tap positions. Several real controls are not expressible that way, because each decides one of those inputs from the solved state:

  • A generator that runs out of reactive capability stops holding its voltage. Its bus is no longer a PV bus; it is a PQ bus injecting exactly its limit.
  • System imbalance is picked up by whichever machines are on governor control, not dumped on one slack.
  • An on-load tap changer moves until the bus or branch it regulates is inside a deadband. Its position is an output.

Each is a loop around the solve rather than a term inside it.

Why this is a chapter rather than three functions

gridoxide grew the first two as standalone entry points — newton_raphson_enforcing_q_limits and newton_raphson_distributing_slack — each constructing its own PersistentSolver and running its own pass counter. The consequence was not a missing feature but an impossible combination: a caller picked one. Real transmission networks want both, and tap control would have been a third.

So they are OuterLoop implementations, and outerloop::solve_with_loops drives an ordered list of them.

#![allow(unused)]
fn main() {
let mut slack = DistributedSlack::new(SlackDistribution::uniform(&buses));
let mut qlim = ReactiveLimits::new();
let mut phase = PhaseControl::new();
let mut voltage = TransformerVoltageControl::new().max_tap_shift(3);

let mut ctx = SolveContext::new(&mut buses, &mut ybus)
    .with_branches(&lines, &mut transformers, &shunts)
    .with_taps(&mut tap_changers, &regulation);

// Innermost first.
let mut list: Vec<&mut dyn OuterLoop> = vec![&mut slack, &mut qlim, &mut phase, &mut voltage];
let (islands, report) = solve_with_loops(&mut ctx, 1e-8, 30, backend, &mut list, 40);
}

An empty list is exactly one ordinary solve. That is not a special case in the code — it falls out of the schedule below — and it is asserted, because it is what makes the layer safe to put underneath every existing caller.

The schedule

Transcribed from powsybl-open-loadflow's AcloadFlowEngine, because it is not the obvious one:

  1. An initial solve runs first. If it does not settle, no loop is consulted at all — there is nothing useful a control can decide from a state that is not a power flow.
  2. The loops are nested, innermost first. Each is run to its own stability — an inner check/re-solve loop — before the next is consulted.
  3. Any loop reporting Unstable re-solves and becomes the "last unstable" loop. The driver then continues down the list, and wraps back to the start.
  4. Termination is reaching the last-unstable loop again having changed nothing on the way: the whole list has been walked and every criterion holds simultaneously, which is what a fixed point of the combined controls means.

With an inner loop that moves twice and an outer one that moves once, the consultation order is

inner inner inner   outer outer   inner
└── to its own stability ──┘ └─┘  └─ re-walk, nothing moves, done

and not inner inner inner outer inner outer. Both halves matter: the first is why an inner control is never left half-converged, the second is why an outer control's decision is re-examined by the inner ones.

budget caps total re-solves across every loop, as one shared figure rather than a per-loop count.

Ordering is a physical claim

The default order — distributed slack, then reactive limits, then phase control, then transformer voltage control — is powsybl's, and it encodes that generators respond faster than tap changers. A tap should be chosen against a reactive dispatch that has already settled, and re-examined when the tap move disturbs it.

Invalidation is the driver's job

Three things must happen together after a tap move — write Transformer::tap, restamp the Y-bus entries derived from it, drop the cached Jacobian — and a loop that does one or two of them is a bug that surfaces as a wrong answer on the next pass rather than as a failure on this one.

A loop therefore declares what it invalidated and the driver acts on it:

LoopWhat it changesInvalidatesWhat the driver does
Distributed slackp_spec onlyNothingnothing — the Jacobian is unchanged
Reactive limitsbus_type, hence n_unknownsPatternPersistentSolver::reset
Tap controlTransformer::tap, hence Y-bus valuesAdmittancesrestamp, then invalidate_admittances

invalidate_admittances drops the cached Jacobian pattern and keeps the per-backend factorization, so the fill-reducing ordering and elimination tree survive — measured at ~45% of solve time on a 9,241-bus case. This is the third caller of the same contract, alongside switching and batch solving.

Remote voltage control, and why it is a loop

A generator regulating the far side of its own step-up transformer is ordinary, and gridoxide has always imported it: RegulatingControl.Terminal resolves to the controlled bus, which is pinned to PV at the target. That gets the target right and puts the reactive power in the wrong place — it appears at the bus being held rather than at the machine, so the reactive flow on the path between them is missing, and the machine's own bus sits at whatever the network makes it rather than at whatever holding the far bus requires.

Stated exactly, this is a small generalization of the PV bus. A PV bus means two things at once — this bus's magnitude is fixed and this bus's reactive injection is free — and remote control separates them: fix (|V|) at the controlled bus, free (Q) at the controller. The unknown count still balances, one magnitude removed against one reactive equation removed:

unknown removedequation removed
local control (the ordinary PV bus)(V
remote control, one machine(V
remote control, (m) machines(V

The third row is short by (m-1) equations, which is what powsybl's DISTR_Q supplies. No vendored fixture has that configuration, so gridoxide does not implement it.

Implementing the second row exactly means the unknown layout stops being derivable from BusType, which is the assumption the Jacobian assembly, the Newton loop, the sensitivities, the batched solve and the continuation all share. That is a large change to the most load-bearing code in the crate, for a configuration two vendored fixtures have and one can exercise.

RemoteVoltageControl reaches the same fixed point without touching any of it. Make the controller bus PV — which is what frees its reactive power, and frees it at the right bus — and drive its own setpoint until the controlled bus reaches the target. At convergence (|V|) at the controlled bus is the target and (Q) at the controller is whatever holding it takes, which is exactly what the exact formulation asserts. The difference is iteration, not answer, and the test suite checks precisely that: pinning the controller by hand at the setpoint the loop landed on, with no loop running, reproduces the same state.

It also inherits the reactive limits properly. The controller bus is a PV bus carrying the machine's own capability, so reactive limits clamp the machine rather than a bus it is not connected to — the more correct bound as well as the easier one.

The step is Δ|V|_controller = gain · (target − |V|_controlled), with gain starting at 1.0 and refined by secant from the response observed. The 1.0 is not arbitrary: a machine holding the far side of its own transformer moves that bus nearly one-for-one in per-unit, so the first step lands close. A derived sensitivity would be the better answer once a second fixture asks for one; deriving it now would be building the general thing on a sample of one.

It is opt-in (--control-remote-voltage, solve(control_remote_voltage=True)) because turning it on changes answers. The default is wrong rather than merely conservative, and that is stated here rather than left to be discovered.

What this is not

Deliberately not an extensibility mechanism. powsybl's OuterLoop is ServiceLoader-discovered so third parties can register their own; that is a Java-ecosystem affordance, and copying it would buy an abstraction nobody outside this crate can reach. The list is built by the crate, and the trait exists to make the loops compose — which was the actual defect.

See also

Reactive Power Limits (PV → PQ Switching)

Motivation

The plain Newton-Raphson formulation on the Powerflow page treats every PV bus as if its generator could supply or absorb any amount of reactive power needed to hold \(\vert V_k \vert\) at its setpoint. Real generators can't: each has a nameplate reactive capability \(Q_k^{min} \le Q_k \le Q_k^{max}\), usually tightest for round-rotor machines operating near their active-power limit. If the voltage setpoint on a PV bus would require \(Q_k\) outside that range, the unconstrained solution is not physically achievable — the generator saturates at its limit and the bus can no longer hold its voltage.

The standard fix is PV → PQ switching: solve normally, check every PV bus's computed \(Q_k\) against its limits, and for any bus that violates one, convert it to a PQ bus with \(Q_k\) pinned at the violated limit — trading the voltage-magnitude equation for a reactive-power equation at that bus — then re-solve. Repeat until no bus violates its limits.

What changes in the equation system

Recall from the Powerflow page that a PV bus contributes only a \(P\)-mismatch row to \(f(x)\) — its voltage magnitude \(\vert V_k \vert\) is known (the setpoint), so it isn't part of the unknown vector \(x\) and there's no \(Q\)-mismatch row for it. A PQ bus contributes both a \(P\)- and a \(Q\)-mismatch row, and its \(\vert V_k \vert\) is part of \(x\).

Switching bus \(k\) from PV to PQ therefore:

  • adds \(\vert V_k \vert\) to the unknown vector \(x\) (it's no longer fixed at the setpoint — the voltage is now free to float),
  • adds a \(Q\)-mismatch row \(Q_k^{calc}(x) - Q_k^{spec}\) to \(f(x)\), with \(Q_k^{spec}\) pinned at whichever limit was violated (\(Q_k^{min}\) or \(Q_k^{max}\)) instead of the bus's original \(q\_spec\),
  • leaves that bus's \(P\)-mismatch row untouched — the same equation, unaffected by bus type.

This changes \(n_{unknowns}\) itself, so the Jacobian's dimensions grow by one row and one column per switched bus. Any cached symbolic factorization (fill-reducing ordering) computed for the old sparsity pattern is invalid once a switch happens and must be redone from scratch.

Switching strategies

That equation-system change is common to every implementation. What differs is when the check runs and whether a switch can ever be undone — three genuinely different designs, in increasing order of bookkeeping.

1. One-directional outer-loop switching

Solve to full convergence, check limits, switch every violating bus to PQ, re-solve; repeat until a pass switches nothing. A bus that has been switched to PQ stays PQ for the rest of the call, even if a later pass's solution would put its \(Q\) back within limits.

The appeal is that the anti-oscillation problem simply doesn't arise: with switching one-way, the set of PQ buses grows monotonically, so the outer loop terminates on its own. The cost is that a bus which was only transiently out of range — say during an early pass, still far from the real solution — is committed to PQ permanently, giving a slightly different, slightly more conservative answer than a scheme that could release it.

2. Bidirectional switching with a switch-count cap

The same outer loop, except a bus moved to PQ may move back to PV in a later pass if its computed \(Q\) has returned within limits — recovering exactly the transiently-violating case strategy 1 gives up on. This reintroduces the risk strategy 1 avoids: a bus can flip between PV and PQ indefinitely across passes, since each type change moves the solution enough to justify the opposite change next time. The standard mitigation is a hard cap on how many times any one bus may switch, after which it is forced to stay wherever it last was.

That cap is real bookkeeping — a per-bus counter carried across outer passes — and it makes the result mildly path-dependent (which bus hits its cap first depends on iteration order). In exchange, no bus is written off on the strength of one early, poorly-conditioned iterate.

3. Mid-iteration switching

Rather than a separate outer loop that fully re-solves after every switch, the check-and-switch step is folded directly into the Newton iteration: once the mismatch drops below some threshold, bus types are switched and the same iteration sequence continues instead of a fresh solve starting. This saves the redundant re-convergence strategies 1 and 2 pay for — the iterate is already near the solution when the switch happens, so it doesn't have to be rediscovered — at the cost of mutating the equation system underneath a running Newton iteration, so the Jacobian's dimensions (and any cached factorization) change mid-flight.

Published versions of this technique also widen the switch-back criterion: "On PV-PQ Bus Type Switching Logic in Power Flow Computation" (Jinquan Zhao) bases the decision to release a bus on comparing its voltage against its setpoint in addition to comparing its \(Q\) against its limits, not on \(Q\) alone.

Where this fits in gridoxide today

outerloop::ReactiveLimits implements strategy 1, as an outer loop around a solver::PersistentSolver:

  1. Solve to convergence with every PV bus free (PersistentSolver::solve). If this doesn't converge, stop and return that status — there's no point checking limits on a non-converged solution.
  2. Compute every bus's actual \(Q_k\) (network::power_injections).
  3. For each PV bus whose \(Q_k\) violates q_min/q_max: switch bus_type to PQ and pin q_spec to the violated limit.
  4. If no bus was switched this pass, the solution is self-consistent — stop and return Converged.
  5. Otherwise, reset the cached factorization (PersistentSolver::reset) and go back to step 1.
  6. If the outer-loop budget runs out without stabilizing, the run reports budget_exhausted rather than dressing the state up as converged.

Steps 4 and 5 are the driver's job rather than this loop's: it returns Unstable after switching anything, declares Invalidates::Pattern, and the driver resets the factorization and re-solves. That separation is what lets reactive limits run alongside distributed slack and tap control rather than instead of them — before the outer-loop layer existed, each was its own entry point and a caller picked one.

Strategies 2 and 3 both add real complexity (anti-oscillation bookkeeping, or reworking the iteration loop itself) to recover a case gridoxide's simpler design just accepts as a one-way commitment — a deliberate scope trade-off, not an oversight.

One further simplification on top of the one-directional rule: bus voltages carry over between outer passes rather than resetting to a flat start. Since only one bus's type changes per pass, the previous pass's converged state is normally very close to the next equilibrium, so re-solving after a switch typically takes only a few extra Newton iterations, not a full fresh solve.

Scope: net injection, not per-device limits

Bus::q_min/q_max bound the bus's net reactive injection — the same aggregate quantity q_spec already represents when multiple loads/generators share a node (see pgm::PgmVoltageRegulator's own q_min/q_max parsing, which sums each active generator's limits at a bus exactly the way p_spec is already summed). Pinning q_spec to a violated limit only achieves that limit exactly if the bus carries no voltage-dependent ZIP-model load terms (Bus::zip_terms) — true for the common case of a bus that's purely a PV/generator connection, not true in general if a voltage-dependent load is co-located there.

Validated against real data

tests/q_limits_test.rs checks the mechanics directly: a 3-bus fixture with one PV bus whose q_min is set tight enough to force a violation, confirmed to switch to PQ, pin q_spec at exactly q_min, and converge to the same voltages across every Jacobian backend (Scalar, Block, Klu) — as well as a control case with a loose q_min that shouldn't trigger any switch at all, converging to the same answer as plain unconstrained Newton-Raphson.

Beyond the unit fixture, this has been exercised against all 12 real MATPOWER benchmark cases (gridoxide.matpower — python/gridoxide/matpower.py, with scripts/bench/matpower_to_pgm.py as a thin CLI wrapper around it — populates voltage_regulator.q_min/q_max from each case's own gen matrix): 11 of the 12 have at least one PV bus whose unconstrained \(Q\) genuinely exceeds its nameplate limit — from 4 violations on the smallest case up to 166 simultaneous violations on case3120sp.

Tool reference

ToolStrategyWhere
gridoxide1 — one-directional outer loopouterloop::ReactiveLimits (opt-in; plain newton_raphson ignores q_min/q_max)
MATPOWER1 — one-directional outer looprunpf's enforce_q_lims option
pandapower1 — one-directional outer loop, pypower/MATPOWER-derivedenforce_q_lims (NR algorithm only, per its own docstring)
powsybl-open-loadflow2 — bidirectional, capped switch count per busReactiveLimitsOuterLoop (handles capability curves too, not just fixed limits)
VeraGrid3 — mid-iteration, per Zhao's switching logic (cited in its own source)PowerFlowOptions.control_q

Which machine, not just which bus

A PV bus has one reactive injection and the solver decides it. The limits above are the summed capability of everything holding that bus — which is the right bound to enforce, and is what the importer accumulates. But it leaves a question unanswered: when six machines hold one bus, which of them is carrying the reactive power?

gridoxide solve --dispatch (and PowerFlowModel.machine_dispatch()) answers it, by attributing the solved injection after the fact:

  • by an explicit per-machine key where the source document states one;
  • else in proportion to each machine's own reactive range;
  • else evenly.

The fallback is wholesale: one implausible or missing value discards the whole basis rather than leaving a split half-derived from capability and half from nothing. Shares are then clamped to each machine's own range and the excess re-split, so no machine is handed more than it can produce while another sits idle.

This changes no answer. It is arithmetic on a solved state, not a constraint inside the solve. Every shared-control case in the vendored CGMES corpus is machines sitting at the bus they regulate, so that bus is an ordinary PV bus with a single injection. The genuinely different problem — several machines at different buses holding one remote bus — needs extra equations inside the Newton system, and is not built, because no fixture exercises it.

What the attribution exposed

It is worth reading unattributed on the result. A non-zero value means the machines at that bus are all at their limits and still cannot account for the reactive power the solve put there.

That is not an arithmetic failure. It happens because the clamp above compares q_min/q_max against the bus's net injection, while the CGMES importer fills those fields from the machines' own capability. Where a reactive load shares the bus, the machine must cover it too — so the machine saturates before the net injection reaches the bound, and the clamp fires late. On RealGrid, 50 of 416 regulated buses share their bus with another reactive injection and 6 come out unattributable, 4.4 MVAr in total.

The simplification was already documented (pgm::PgmVoltageRegulator's doc comment names it); nothing had measured it before. Correcting it means bounding the machine part rather than the net, which changes power-flow answers and so is deliberately not folded into an attribution pass.

Distributed Slack

Motivation

An ordinary power flow needs one bus whose active power is free. Load is specified, generation is specified, but transmission losses are not known until the solve finishes — so something has to absorb whatever the schedule does not cover. That something is the slack bus.

The trouble is that "whatever the schedule does not cover" is not small. It is the sum of every scheduling mismatch plus all system losses, and an ordinary solve puts all of it on one machine. On case118_ieee that is 16.5 per-unit — 1,650 MW — appearing at a single bus that was never scheduled for it.

Real systems do not behave that way. Generators on governor control pick up imbalance together, in proportion to their droop settings, and the plant at the reference bus is not special. The single-slack answer is therefore wrong in a specific and predictable place: the branches around the slack, which carry an injection no real dispatch would put there. Those are frequently the branches you were looking at.

The formulation

Give each bus a participation weight \(\alpha_i\), and let the slack's own p_spec be its schedule. Then:

  1. Solve the power flow normally.
  2. Measure the slack's excess over its schedule, \(\Delta = P^{calc}{slack} - P^{sched}{slack}\).
  3. Add \(\alpha_i \Delta / \sum_j \alpha_j\) to every participating bus's schedule.
  4. Solve again, and repeat until \(|\Delta|\) is below tolerance.
#![allow(unused)]
fn main() {
use gridoxide::outerloop::{DistributedSlack, OuterLoop, SlackDistribution, SolveContext};
use gridoxide::solver::JacobianBackend;

// Every generator bus — Slack and PV — takes an equal share.
let distribution = SlackDistribution::uniform(&buses);

let mut slack = DistributedSlack::new(distribution);
let (islands, _) = {
    let mut ctx = SolveContext::new(&mut buses, &mut ybus);
    let mut list: Vec<&mut dyn OuterLoop> = vec![&mut slack];
    gridoxide::outerloop::solve_with_loops(&mut ctx, 1e-10, 30, JacobianBackend::Scalar, &mut list, 20)
};
let report = slack.report();

println!("moved {:.3} pu off the slack", report.shift.iter().sum::<f64>());
}

SlackDistribution::from_weights takes explicit weights instead. They are normalized, so raw megawatt headroom or droop constants can be passed without pre-dividing.

The slack's schedule is its own p_spec

Ordinary power flow ignores that field for a slack bus — the slack's output is an answer, not an input — which is exactly what leaves it free to carry the schedule here.

The consequence is worth stating plainly: a document that never needed the field may leave it at zero, in which case the slack is treated as scheduled for nothing and its entire output is redistributed. That is a coherent answer to a coherent question, but it is probably not the question you meant. Set it if the reference plant has a real schedule.

Weights normalize per island, not globally

A disconnected network has one slack and one imbalance per island. Normalizing globally would size one island's correction by another island's generators — an island with large machines would quietly dominate the split in an island it is not even connected to.

So the sum in step 3 runs over that island's buses only, which is also why the report names islands it could not distribute over rather than skipping them.

Convergence

The first-order term cancels in a single pass, and that is what makes an outer loop affordable here at all. Distributing \(\Delta\) adds \((1 - \alpha_s)\Delta\) to the other participants' schedules, so the next solve asks the slack for exactly that much less — while its own schedule has risen by \(\alpha_s\Delta\). The two cancel.

What is left over does not vanish. Moving generation changes the flows, which changes the losses, which changes the imbalance. So convergence is linear at roughly the fractional loss sensitivity — a few per cent per pass — rather than the one-and-done the cancellation alone suggests:

CasePasses to 1e-8Moved off the slack
case14_ieee72.3 pu
case30_ieee72.4 pu
case118_ieee716.5 pu

Seven passes is not seven full solves' worth of work, because of the next point.

The factorization survives the whole loop

Only p_spec changes between passes. Bus types do not, so n_unknowns does not, so the Jacobian's sparsity pattern does not — and PersistentSolver keeps its symbolic factorization across every pass.

That is the opposite of the Q-limit loop, which switches buses PV → PQ and must therefore reset() each time it does. The two outer loops look alike and cost very differently.

Reading the result

buses[i].p_spec ends up holding the dispatched value rather than the schedule it went in with. That is the answer — who ended up producing what — and report.shift records how far each bus moved, so the original is recoverable.

report.undistributed names any island left on a single slack, with the reason: no reference bus, an ambiguous one, or no participating generator in that island. This matters more than it looks. A caller who mis-specifies weights gets a perfectly ordinary single-slack answer, which is a correct answer to a different question — the report is the only thing distinguishing it from success.

How it is checked

tests/distributed_slack_test.rs. The defining property is that the slack ends up producing its schedule and nothing more, but the more interesting check is that the shifts sum to the imbalance a single-slack solve put on that bus: a formulation that quietly created power would still converge, and would still leave the slack at its schedule, so only the accounting catches it.

Every check re-derives injections from network::power_injections at the returned state rather than reading the loop's own bookkeeping, so the outer loop cannot corroborate itself.

Is area-interchange control the same thing?

Closely related — it is this, generalized from one balance target to several. Not a different mechanism.

Distributed slack has one balance condition per island: the slack produces its schedule. Area interchange control partitions the network into control areas, each with a scheduled net exchange with its neighbours, and asks that every area's tie-line flows sum to its schedule. Both are "adjust generation among participating units until a power-balance target is met"; the difference is how many targets there are and what defines them.

powsybl-open-loadflow makes the relationship explicit rather than incidental. Its AcAreaInterchangeControlOuterLoop constructor reads:

super(activePowerDistribution,
      new DistributedSlackOuterLoop(activePowerDistribution, slackBusPMaxMismatch),
      slackBusPMaxMismatch, areaInterchangePMaxMismatch, LOGGER);

It constructs a distributed-slack outer loop and holds it as the fallback used when the network defines no areas, shares the same ActivePowerDistribution engine for the redistribution itself, and reuses DistributedSlackContextData for its bookkeeping. Distributed slack is literally the degenerate case.

gridoxide implements it too, as outerloop::AreaInterchange — and the relationship powsybl's code implies is an assertion here rather than a remark: one area with a zero target reproduces distributed slack's per-bus shifts and solved voltages exactly. The three additions it needed were the ones this paragraph predicted when it still read "gridoxide does not implement area interchange control": an area assignment per bus, a scheduled interchange per area, and a per-area mismatch computed from tie-line flows in place of the slack deviation used here.

What the prediction missed is that the problem is over-determined by exactly one. Both ends of a tie are measured into the branch, so a set of agreed net positions sums to the tie losses rather than to zero; the knobs are one schedule per area and the conditions wanted are that many plus the slack on its own schedule. So the slack's own area is the dependent one: its target is not enforced, it absorbs the losses, and the report names it. See Area Interchange Control.

Area Interchange Control

Motivation

Distributed slack drives one number to one target: the slack's output to its own schedule. Interconnected systems schedule more than that. Each control area agrees a net position with its neighbours — how much it will export across its tie lines over the hour — and dispatches its own generators to meet it. An area importing 300 MW when it agreed to import 200 has every tie-line flow around it wrong, for exactly the reason a single slack absorbing an unscheduled few hundred megawatts does.

The formulation

An area's interchange is the active power leaving it across its own boundary branches, measured at its own side of each:

\[ X_a = \sum_{b \in \partial a} P_b^{(a\text{-side})} \]

A branch is a boundary of area \(a\) when exactly one of its ends is in \(a\) — including the case where the other end is in no area at all. The knob is the one distributed slack already uses: the participating generators' p_spec.

One area's position is dependent, and it has to be

Both ends of a tie are measured into the branch, so they do not cancel — they sum to what the tie dissipates:

\[ \sum_a X_a = +\ell_{\text{tie}} \]

This is the easy thing to get backwards. It is not that one area's export is the other's import: the loss falls between them and belongs to neither.

The consequence is structural. A set of agreed net positions sums to zero, but the achievable sum is \(\ell_{\text{tie}}\), which nobody knows before the solve. Counting the freedom:

Knobseach area's aggregate participant schedule — \(N\)
Conditions wanted\(N\) interchange targets, plus the slack on its own schedule — \(N+1\)

Over-determined by one, always. So the area holding the slack is the dependent one: its own target is not enforced, and its condition is that the slack produces its schedule instead. Every other area's position is met exactly, and the slack's area takes the residual.

That residual is the tie losses when the areas partition the network — but only then. A bus in no area is on the boundary of whichever areas it touches, so anything it injects lands in the residual too. CGMES makes this the normal case rather than an edge one: a TieFlow names the boundary node, which in a merged model is the X-node two areas' lines meet at, and that node belongs to neither. On MicroGrid the five X-nodes inject nothing, so the whole 53 MW residual is the boundary lines' own losses — which are large because it is a truncated-boundary model, not because anything is wrong.

That is what a real interconnection does — one area's position ends up a residual rather than an agreement — and gridoxide reports it as such rather than implying it. AreaInterchangeReport::dependent names the area, and its residual entry is how far its nominal target was missed.

The sign

For an area that is not the slack's, exporting above target means it should generate less, so \(X_a - X_a^{target}\) is subtracted from its participants by normalized weight.

For the slack's area, write \(\delta_s\) for the slack's excess over its own schedule. Its participants must take that excess up, so the quantity subtracted is \(-\delta_s\).

It generalizes distributed slack rather than sitting beside it

One area covering the whole network has no boundary branches, so \(X = 0\); that area is the slack's area, so its condition is \(-\delta_s\); and the loop becomes distributed slack exactly.

That is asserted, not asserted-about: area_interchange_with_one_area_is_distributed_slack runs both loops on one network and compares the per-bus shifts and the solved voltages. A flipped sign in the derivation above makes it diverge instead of agree.

It is also why the two must not both be configured — run_power_flow refuses rather than dispatching the same generators twice — and why powsybl-open-loadflow's own AcAreaInterchangeControlOuterLoop constructs a DistributedSlackOuterLoop as its no-area fallback and its filterInconsistentOuterLoops removes the latter when the former is present.

Where the areas come from

All three importers supply them, and they supply different amounts.

CGMES states both halves. ControlArea carries the schedule as netInterchange, and a TieFlow per boundary terminal states the boundary. cgmes::cgmes_control_areas turns that into an assignment and a target.

CGMES defines an area by its boundary and never lists what is inside, so membership is derived: take the branches the tie flows name, cut them, and compute the connected components of what remains. Each component containing an area's seed is that area. A component reached from two areas' seeds is a contradiction in the file's own boundary, reported rather than arbitrated.

One detail is worth stating because getting it wrong is quiet. A TieFlow names the terminal at the boundary, not inside the area — in a merged model that is the X-node where two areas' lines meet, and TN_Border_AL11 carries both NL-Line_1's and BE-Line_3's tie flow. What belongs to the area is the equipment; the node is shared. So the seed is the cut branch's far end. Seeding from the named end put every area's seed on the same border node, which showed up as five contested buses and one area owning nothing at all.

The sign matters as much. CGMES states netInterchange as an import — "positive sign means flow in to the area" — and AreaDefinition::targets is an export, so the importer negates. Checked against the fixture's own data rather than against the specification alone: SmallGrid declares a 210 MW position and its published state already sits at 210.271 MW measured, which it could not with the sign flipped.

IIDM states membership directly, and it is the only one that does. <iidm:area> lists the voltage levels inside it as <voltageLevelRef>, so nothing is derived — a bus belongs to whichever area claims any of its nodes' voltage levels, and a bus merging nodes from two areas is a contradiction the report counts. interchangeTarget gives the schedule where a file states one, in the same load sign convention CGMES uses ("negative is export, positive is import", per Area.java's own javadoc), so the same negation applies.

<areaBoundary> elements are counted and not read: gridoxide derives the boundary from membership, so a stated one is redundant here. An areaType other than ControlArea — a BiddingZone, say — partitions the network for a different purpose and is skipped, counted so its absence is visible.

UCTE states membership only. ##Z<cc> sub-headers are a bus-to-area assignment and nothing more, exposed as UcteImport::country_areas. No UCTE file anywhere states a scheduled net position, so the caller supplies the targets — and the default of zero asks each country to serve its own load, which is the honest reading of "no interchange agreed". The twelve-node case is a real four-country interconnection whose own state has BE exporting 2000 MW and DE importing 2500.

Both are reachable from the command line:

gridoxide solve <network> --area-interchange

A cross-format check worth having

The twelve-node case exists as both .uct and .xiidm, and ucte_and_iidm_agree_exactly_on_the_twelve_node_case already establishes they are the same network. So the areas must agree too — derived from ##Z country codes on one side and stated as <voltageLevelRef> on the other, by entirely separate code. They do: BE +2000 MW, DE −2500, FR +1000, NL −500, to 1e-6.

Which participants, and why it differs from distributed slack

AreaDefinition::uniform participates Slack and PV buses only, matching SlackDistribution::uniform. That policy is right for distributed slack, which is frequency response: a machine not under governor control does not pick up imbalance.

A net position is not frequency response. It is met by redispatch, and a generator held at a fixed active set-point is precisely the machine an operator redispatches. AreaDefinition::by_generation weights by p_spec at every bus instead, and the command line uses it.

What the wrong policy costs is visible in powsybl's own two_area_case.xiidm: both of AREA2's generators are voltageRegulatorOn="false" and so arrive as PQ buses, leaving that area with no participant at all under uniform and its −400 MW schedule unreachable. Under by_generation it reaches −400.000 exactly.

AreaInterchange::measure is public for a related reason, and is useful without the loop: "what is this area actually exchanging" is worth asking of a solved network without controlling it.

Deliberately out of scope

  • Several slacks per area, and areas spanning islands. The first area found holding a slack is the dependent one; an island with its own reference balances through its own participants.
  • Boundary-point areas, where a bus sits on a tie whose flow the area does not count toward its own position. powsybl handles this by attributing the slack across the areas that do count it.
  • A per-area tolerance, and powsybl's separate areaInterchangePMaxMismatch beside slackBusPMaxMismatch. One tolerance covers both here.

See also

Transformer Tap Control

Motivation

Every steady-state result in this book before this chapter — power flow, contingency, sensitivity, OPF, state estimation, the RAO gate — was computed with transformer taps that never move. That is a modelling assumption, and four of the five tools in the feature comparison do not make it.

A real on-load tap changer holds something. Given a target and a deadband, it steps its position until the quantity it regulates is inside:

\[ \text{find } t \in {t_{\min},\dots,t_{\max}} \ \text{ s.t. }
\left|V_c(t) - V^{\text{target}}\right| \le \tfrac{1}{2},\text{deadband} \]

with \(V_c\) the voltage at the controlled bus, which need not be either of the transformer's own. A phase shifter substitutes a monitored branch's active power for the voltage.

The strategy, and why this one

powsybl-open-loadflow offers three strategies for voltage control. Two put the continuous ratio inside the Newton system and round afterwards. The third steps the discrete position directly, from the sensitivity of the controlled quantity to the tap:

\[ \Delta\rho = \frac{V^{\text{target}} - V_c}{\partial V_c/\partial\rho}, \qquad \Delta\alpha = \frac{P^{\text{target}} - P_b}{\partial P_b/\partial\alpha} \]

The incremental one is what gridoxide implements, for a reason specific to this crate: those derivatives are already ac_sensitivity::AcSensitivity, with Variable::TransformerRatio against Function::VoltageMagnitude and Variable::PhaseShift against Function::BranchActivePower, validated against a central-difference re-solve of the full nonlinear power flow. The continuous variants would need the ratio inside the Jacobian pattern and a different n_unknowns.

One consequence is worth stating. AcSensitivity carries a direct \(\partial f/\partial p\) term for a tapped branch's own flow, on top of the chain rule. A phase shifter usually regulates its own flow, which is exactly that configuration, and dropping the direct term flips the sign of the answer on the regulated branch while leaving every other branch correct.

Three guards, all load-bearing

Deadband. Every enabled control in the CGMES conformity fixtures carries one — Svedala's are ±1.3% to ±1.9% of nominal, FullGrid's phase control is ±17.5 MW on a −65 MW target. A loop that ignores the deadband hunts, and a loop that hunts does not terminate.

Insensitivity filter. Below MIN_SENSITIVITY (powsybl's own 0.05) a controller is declared unable to reach its quantity. Without it \(\Delta\rho\) is a division by nearly zero and the tap slams to a limit.

Direction-change budget. A tap that has reversed MAX_DIRECTION_CHANGES times is oscillating between two positions that straddle the target with none inside the deadband. That is a property of the network and the deadband, not a solver failure.

The sensitivity is exact only at the converged state, so max_tap_shift caps how far a controller may move in one pass — trading passes for stability.

What a controller reports

OutcomeMeaning
InDeadbandInside. The success case.
ClosestThe best position the table offers, target still out of reach.
AtLimitIt asked for a ratio or angle the changer cannot produce at all.
InsensitiveIt cannot move its own controlled quantity.
HuntingIt reversed direction too often to be converging.
UnfinishedThe run ended before it settled.

Closest is common rather than exceptional, and distinguishing it from InDeadband matters. A tap moves in finite steps, so a deadband narrower than one step's worth of effect is unreachable from every position. ENTSO-E's own PST_PhaseTapChangerLinear_Type2 asks for zero flow within 5·10⁻⁴ pu on a shifter whose closest position leaves 0.11 pu — reporting that as InDeadband claims success at a flow 200 times the tolerance.

Several controllers, one target

Parallel transformers in a substation regulate one bus; FullGrid has two phase shifters holding one branch's power. Both loops group by the quantity being held and size the controllers against each other — most authority first, each one's achieved effect subtracted before the next is sized. Sizing them independently doubles the correction and sets both oscillating.

This is deliberately not the "last writer wins" behaviour the comparison table records for gridoxide's generator-side shared voltage control. It is also the cheaper half of that problem, since taps sit outside the Newton system.

Where the data comes from

ImporterTap tableRegulating control
CGMESevery position of all four PhaseTapChanger flavours plus RatioTapChangerTableTapChangerControl, in voltage and activePower modes
IIDM<step> elementsregulating, targetV/regulationValue, targetDeadband, regulationMode
UCTE-DEF##R tap fields##R columns 33–38 (kV) and 58–63 (MW)
PGM, native JSON——

CGMES is where the gate is, which is the opposite of the RAO work: there, UCTE and IIDM carried everything and CGMES carried nothing. Not one of the 207 ##R records in the vendored UCTE corpus populates a target column, and the committed IIDM fixtures have no regulating changer either.

A CGMES PhaseTapChangerLinear/Symmetrical/Asymmetrical carrying xMin/xMax genuinely changes reactance as it moves. TapChanger::series carries that per position, and set_position writes it alongside the ratio — moving the phase shift while leaving the impedance at the exported position is a wrong answer that still moves the flow in the right direction.

Validation

Three kinds, in increasing order of how much they depend on anything outside the repository.

Analytic. A two-bus network whose right position is computable from the step table. The loop must find it from every starting position, and find the same one regardless of where it started.

Against an exhaustive sweep. Solve at every position in range and check the loop's answer against what the sweep says. This is what catches a sign error in \(\partial V/\partial\rho\): a loop that moved the tap the wrong way would still change the voltage, still terminate, and still look plausible.

Against the conformity fixtures. On Svedala all eleven controllers go from 2–5% outside their deadbands to inside, and the solve still converges. A per-controller sweep, every other tap held where the loop left it, confirms each chosen position.

A finding worth recording

The obvious gate — Svedala's SSH tap positions equal its SV ones, so a converged loop must leave all eleven alone — has a false premise. The fixture's own published solution sits 2–5% away from the targets its own SSH declares, against half-deadbands of 1–2%. The published state does not satisfy the controls the document records, and a loop that left those taps alone would be the defective one.

This says nothing bad about the fixture: a conformity model exists to exercise a profile's classes, not to be a converged operating point. But it does mean Svedala cannot serve as evidence that the loop leaves correct taps alone. SmallGrid, PowerFlow and MiniGrid carry that instead — between them ten tap tables, one disabled control and no live one, so nothing may move.

Deliberately out of scope

  • powsybl's two continuous-then-round voltage strategies (§ above).
  • reactivePower-mode tap control, and IIDM's CURRENT_LIMITER phase mode, which holds a current. Both are recognised and counted rather than silently read as something else.
  • Shunt-section control: the same outer loop over a different discrete device.
  • Tap control inside contingency and batch solves, and in DC.
  • Taps as OPF decision variables — a regulating tap and an optimized tap are different questions.

See also

Ideal Switches and Zero-Impedance Branches

Motivation

Real networks contain connections that are, by design, not really "branches" in the sense the Powerflow page assumes: breakers, disconnectors, bus-bar couplers, jumpers — elements meant to either tie two points electrically together with (idealized) zero impedance, or fully separate them, with no impedance value in between and no partial state.

Modeling one as an ordinary branch runs straight into the admittance formulation the rest of that page relies on. A branch's contribution to the bus admittance matrix is built from \(Y = 1/Z\); with \(Z = 0\) that's an infinite entry, not a large-but-finite one. And this isn't just a numerical inconvenience to work around — a closed zero-impedance connection isn't naturally a "branch" at all. Its physical meaning is an equality constraint, \(V_i = V_j\), not a current flowing in proportion to a voltage difference. That's a fundamentally different kind of equation from every other term in \(f(x)\), which is exactly why real tools solve this with dedicated mechanisms rather than a variant of the ordinary branch stamp.

Three approaches

1. Topological reduction (merge before you formulate)

The most direct fix is to never let the zero-impedance connection reach the admittance formulation in the first place. Build a graph of nodes connected by closed zero-impedance edges, find its connected components, and treat each component as a single node in the actual power-flow model — an open switch is simply not an edge, so it doesn't merge anything.

This adds no new equations, no new unknowns, and no numerical stiffness. The costs are that it happens before the solver ever runs, as a separate graph pass over the input model, and that the two original terminals lose their distinct identity — nothing downstream can distinguish "flow through the left side of the coupler" from "flow through the right side" once they've become one node. Switching state also can't change between solves without redoing the reduction and rebuilding the model.

2. Large-admittance regularization

A second approach keeps the zero-impedance connection as an ordinary branch, but assigns it a series admittance many orders of magnitude larger than anything else in the network (say 106 S in a 10 kV network) and zero shunt admittance. The same nodal equations that already exist for every other branch then force near-equality of the two bus voltages as a natural numerical consequence — no new equation type, no graph pass, just an extreme parameter value. Open/closed status needs no special handling either: it's the same connection-status flag every other branch already has.

The trade-off is conditioning: introducing one admittance value five or six orders of magnitude larger than the rest of the matrix widens its dynamic range substantially, which is exactly the kind of thing a direct sparse solver's pivoting has to work harder to stay accurate through as a network grows. That's a real cost, paid deliberately in exchange for needing no separate topological pre-processing stage at all — the ideal connection is just a branch, as far as the rest of the solver is concerned.

3. Equality-constrained augmented system

The third approach takes the constraint interpretation from the Motivation section literally: instead of merging the two nodes away or approximating the constraint with an extreme admittance, add the equation \(V_i = V_j\) (and, in polar form, \(\theta_i = \theta_j\)) directly into the Newton-Raphson system as its own row. Since adding an equation without adding an unknown would leave the system over-determined, a pair of new "dummy" variables is introduced alongside it — one at each of the two buses, equal and opposite, representing the (otherwise unmodeled) power flowing through the ideal connection to make the constraint hold. The system stays exactly square: one new equation, one new pair of unknowns.

A complication this approach has to handle and the other two don't: a loop of zero-impedance branches produces a linearly dependent constraint set (the last edge's constraint is implied by the others), which would make the augmented system singular. The standard fix is to compute a minimum spanning tree over each connected group of zero-impedance branches and constrain only the tree edges, leaving the redundant non-tree edges as inactive constraints that can be reactivated if the tree has to be re-routed after a later switching operation.

Compared to the other two, this is real bookkeeping — the spanning-tree maintenance in particular has no analogue in either simpler approach — but it's also the only one of the three that keeps both original terminals numerically distinct after the fact, which matters if anything downstream needs to report or reason about flow through each side of the connection separately.

What implementing it settled

constrained::solve_constrained implements this, and tests/constrained_switch_test.rs checks it against approach 2 on networks the two must agree on. Four things the sketch above did not predict:

  • The spanning tree is not extra bookkeeping — it is a disjoint-set forest built in one pass over the switch list, and it does double duty. Seed it with the buses whose unknown is already fixed, all in one set, and "these two are already connected" catches both redundancies with one test: a cycle of closed switches, and a run of switches whose two ends are both Slack. The second is the one that bites — SmallGrid's full node-breaker view contains it, and it produces a duplicate row and a singular matrix. Looking at each switch's own two ends does not find it.
  • The redundant edges are not "inactive" — they keep their rows and columns and constrain \(P_s = Q_s = 0\) instead. That is what keeps the sparsity pattern identical across switching states, which is the same trick an open switch uses.
  • A flat start does not work on real data. The guess has to see the switches, which means one linear solve against a Y-bus that does stamp them stiffly. The conditioning argument survives intact — a starting guess is discarded on the first step, and the matrix Newton factorizes never contains the constant — but the formulation is not usable without it.
  • It is not faster, and it was never the reason to want it. Every node-breaker configuration converges in the same iteration count under both approaches (2 on MiniGrid, 5 on SmallGrid, 6 on Svedala, with all 1,464 of Svedala's switches constrained). What approach 3 gives that approach 2 cannot is an honest answer where the flow is undetermined: a loop of closed switches has a free parameter in it, and regularization resolves it with the stiffness it happened to pick.

Where this fits in gridoxide today

gridoxide's solver core has no node-breaker layer: every Bus reaching network::build_ybus is already a fully-resolved electrical node. Topology resolution is a precondition of the Bus/Line/Transformer model, so the choice above is made once, at import.

The rule: identity, not impedance

Merge only when the element has no identity in the output model. Otherwise it stays a branch.

ElementIdentity?Treatment
CGMES closed switchNo — the bus/branch view is the merged viewMerge (cgmes::merge_closed_switches)
PGM linkYes — power-grid-model's output schema carries a link record with its own flowsBranch, at pgm::LINK_Y
Any branch with \|Z\| below a thresholdYes — it is a lineBranch, clamped to a link's stiffness

The deciding evidence is what the fixtures assert. All four upstream power-flow cases containing a link publish that link's own current, and vision-validation-network publishes its full p/q/s at both ends. Two state-estimation fixtures publish per-node injections either side of a link. Merging deletes the branch those numbers describe, so it is unavailable wherever they are asserted — branch satisfies all eight, merge satisfies two.

Note this is decided by what the element is, not by what is attached to it. An earlier version of this rule asked whether the endpoints carried appliances, and vision-validation-network's link 74 refutes it: the link joins two nodes with no appliances at all — apparently the safest possible merge — and power-grid-model still reports 1.964 MW flowing through it.

The threshold row exists because a connection can be electrically zero-impedance without being declared as one. ill-conditioned-by-line-meshed carries a line at 7.07e-9 p.u.; only a value-based test catches that, which is powsybl's framing rather than power-grid-model's.

The numbers, and why they were measured

Both constants were chosen by sweeping, not derived, because the two calculation types pull in opposite directions:

  • Power flow wants a link stiff. The drop across it is \(\Delta V = I/y\), and the fixtures check node voltages and the link current at 1e-5 relative.
  • State estimation wants it soft. \(G = H^{T}WH\) squares the admittance, so power-grid-model's 1e8 becomes 1e16 in the gain matrix.
ypower flowstate estimation
1e8 (power-grid-model's)passsingular
1e6passsingular
2e5 (pgm::LINK_Y)passconverge
1e5fail, exactly at toleranceconverge

The window is about one order of magnitude wide. That narrowness is the argument for treating these as regularization parameters with measured values rather than physical constants: a network far outside these fixtures' power scale may need them re-measured, and if no value serves both, approach 3 is the exit — it imposes \(V_i = V_j\) exactly, with no large number anywhere.

topology::ZERO_IMPEDANCE_THRESHOLD is 1e-7 p.u. on the same basis: measured across all 86 branches in the committed PGM fixtures and every CGMES one, it sits above the single pathological line at 7.07e-9 and below the smallest legitimate branch (1.0e-6 in PGM, 2.92e-6 in CGMES), so it disturbs nothing currently modelled.

Detection and treatment are separate numbers. The threshold only decides whether a branch is an ideal connection; a branch it catches is clamped to IDEAL_CONNECTION_Y, the same admittance a declared link gets. One number cannot do both jobs: it has to sit below every legitimate branch, yet clamping merely to that level leaves \(|Y|\) as high as 1e7 — inside the range measured as singular for state estimation, and 35x stiffer than a link. Separating them satisfies both, and says the right thing besides: a line that short is an undeclared link, so it should be treated as one.

powsybl's own threshold is 1e-8, but its default treatment is the equality-constrained formulation rather than a clamp, so it never has to reconcile the two roles in a single value.

Consequences of the merge, where it is used

CGMES inherits approach 1's limitations directly: switching state cannot change between solves without re-importing, and no per-side flow is reportable across a merged switch. There is also an empirical reason that side merges rather than stamping branches — it was tried, and the AC Newton-Raphson solve diverged on FullGrid with 20-odd such branches active at once, which is exactly the conditioning cost approach 2 carries.

Tool reference

ToolApproachWhere
gridoxide1 and 2, by element identitycgmes::merge_closed_switches merges closed CGMES switches (union-find, shared via topology); PGM link is stamped as a branch at pgm::LINK_Y; any branch below topology::ZERO_IMPEDANCE_THRESHOLD is clamped to the same admittance
powsybl-core1 — topological reduction, in the bus/branch viewgraph traversal of a VoltageLevel's node-breaker topology terminates at open switches and fuses everything reachable through closed ones into one CalculatedBusImpl
power-grid-model2 — large-admittance regularizationthe Link component: an ordinary two-terminal branch with a large fixed series admittance ("1e6 Siemens in a 10kV network", scaled to the network's base) and zero shunt; no special status in Topology::build_topology
powsybl-open-loadflow3 — equality-constrained augmented systemLfZeroImpedanceNetwork groups zero-impedance branches per component and runs Kruskal's algorithm; AcEquationSystemCreator.createNonImpedantBranch emits ZERO_V/ZERO_PHI equations with a DUMMY_P/DUMMY_Q variable pair per spanning-tree edge, non-tree edges inactive

powsybl uses both approaches 1 and 3, at different layers and for different reasons: closed switches that its bus/branch view merges away never reach the solver at all, while retained switches that survive into a node/breaker solve get the augmented-system treatment.

Multi-Island Power Flow

Motivation

Real networks aren't always one connected system. Switched-out feeder sections, decommissioned equipment, or genuinely separate synchronous areas can leave a Y-bus with more than one disconnected component. Before this feature, newton_raphson solved the whole bus list as a single system regardless: if any disconnected component had no slack/reference bus in it, that component's Jacobian rows were structurally singular (no reference angle to anchor them against), and the entire solve failed — even when every other component was perfectly solvable on its own.

The concepts

Supporting disconnected networks means answering four separate questions. They're largely independent — a tool's answer to one doesn't determine its answer to the next.

1. What counts as "one island"?

The obvious definition is a connected component of the network graph. But there are two defensible graph definitions once DC links exist:

  • Connected component — traverse AC branches and DC/HVDC links.
  • Synchronous component — traverse AC branches only.

Two AC areas linked only by an HVDC tie are one connected component but two separate synchronous components. That's physically the right distinction, because a DC link carries power without providing angle/frequency synchronization between its two sides: each side needs its own angle reference, so each side is its own power-flow problem.

A tool with no DC modeling at all doesn't need the distinction — the two notions coincide for it — but the seam is worth keeping conceptually, since retrofitting it after the fact is harder than carrying a second, currently-redundant notion of "component".

2. Which components actually get solved?

Two answers in practice:

  • Main component only — find the largest component, solve it, drop the rest. Cheap and matches the common case where everything but the main grid is modeling debris, but the dropped buses silently produce no result at all.
  • Every component — solve each one independently. Every bus in the input gets an answer, at the cost of doing work on components a user might not care about.

Note that this choice is separable from the classification itself: the classification (what the components are) can run unconditionally while only the selection of which to solve is configurable.

3. What happens to a component with no reference bus?

A component containing no slack/source bus has no anchor for voltage angle — it's not a solvable power-flow problem, no matter how well-formed the rest of the network is. Three distinct responses:

  • Fail the whole solve — structurally correct in that the system really is singular, but it destroys the results for every other component too. This is the behavior the motivation above describes as the actual bug.
  • Emit a null/zero placeholder — mark the component's buses with fixed \(V = 0,\ P = Q = 0\) so they contribute nothing to the shared system, and report them as unsolved. There's no principled way to fabricate a reference phasor for a genuinely sourceless island, so none is invented.
  • Drop the component entirely — the natural consequence of "main component only"; the buses just aren't in any output.

The mirror-image case is a component with more than one reference bus. That's physically over-determined — two independently fixed slack phasors in one electrically connected component — but it is not necessarily numerically singular, so Newton-Raphson will happily converge to a result satisfying neither slack's true power balance. A mismatch-based convergence check can't detect this after the fact; it has to be decided up front from the topology.

4. How is the result reported?

A single overall status (Converged/Diverged) can't describe a network where one island converged and another didn't. Per-island reporting means one status per component, so a caller can tell "the part I care about converged" from "everything failed".

There's a real limitation to be honest about here: if every component is solved in one shared sparse factorization (as opposed to genuinely separate solves), a singularity detected on the result vector can't be attributed to a specific component — the factorization is one object. Per-island status after a shared solve is therefore best-effort for that particular failure mode, and exact only for the ones decided from topology up front.

Where this fits in gridoxide today

gridoxide solves every solvable component in one call — no mode to choose, no opt-in; this is simply what run_power_flow_analysis/run_power_flow_analysis_from_ybus do. Sourceless components get the zero-voltage placeholder, and each component gets its own status.

The key architectural fact that makes this cheap rather than a solver rewrite: network::YBusSparse already exposes everything needed (n(), row(i)) to build a connectivity graph with zero new plumbing, and every Jacobian backend already excludes BusType::Slack buses from its unknowns with no assumption that there's exactly one Slack bus anywhere in the whole bus list. So marking a sourceless component's buses as fixed Slack placeholders makes the existing newton_raphson/PersistentSolver correctly solve every other component in the same shared call — mathematically equivalent, iteration for iteration, to solving each disconnected component independently, since there's no Jacobian coupling between them. Zero changes to the solver algorithm itself were needed; this is a data-preparation pass before the existing solve and a status-reporting pass after it.

gridoxide has no DC-side traversal in this pass today, so its notion of "component" is both notions at once (§1) — crates/gridoxide-core/src/dc.rs's HVDC converters are resolved into AC-side injections before build_ybus runs, so a DC link is not an edge in the graph this pass walks, making its components synchronous components by construction.

The pipeline

The partitioning pipeline lives inside solver::PersistentSolver::solve / solver::newton_raphson_with_backend / solver::newton_raphson themselves — not in run_power_flow_analysis_from_ybus. That means every production entry point shares one mechanism: run_power_flow_analysis_from_ybus is a thin convenience wrapper (build a Y-bus, run linear_initial_guess, construct a one-shot PersistentSolver, call .solve()), and a caller who reaches for PersistentSolver, newton_raphson_with_backend, or newton_raphson directly — to reuse a cached factorization, pick a specific backend, or set a custom tol/max_iter — gets the exact same island handling for free, with no separate opt-in and no risk of silently bypassing it. All three return Vec<solver::IslandReport> (previously SolveStatus/()).

batch::BatchSolver inherits it too, since each of its workers calls PersistentSolver::solve per scenario — batching changes how many solves run and on which thread, not what a solve does.

One deliberate exception: bde::solve_batch_block_diagonal does not partition islands. It stacks every scenario's Jacobian into one block-diagonal sparse matrix to validate the GPU path's architecture (see scripts/bench/README.md §4d), and is an architecture validator rather than a production entry point — its own module documentation says so and points CPU callers at BatchSolver. Worth stating here rather than only there, because "every entry point partitions islands" is exactly the kind of invariant that gets relied on later. If BDE ever becomes a production path, island partitioning has to be added to it: a sourceless component would otherwise make its whole block singular, and because the blocks are structurally disjoint (§4 below), that block alone — not the batch — would be the part that fails.

Each of those functions does, internally:

  1. network::connected_components — an iterative DFS over YBusSparse's own adjacency, generic over the finished Y-bus (so it works uniformly for native JSON, PGM-JSON, and CGMES input with no format-specific code).
  2. network::classify — counts each component's existing Slack buses:
    • exactly one → normal, solvable.
    • zero → network::mark_unreferenced_islands pins every member bus to a fixed V = 0, P = Q = 0 placeholder. There is no principled way to fabricate a reference voltage/angle for a genuinely sourceless island, so none is attempted — every unit/sign-convention bug this project has actually fixed got fixed by matching verified physical or reference-implementation behavior, never by guessing, and inventing a slack here would repeat that mistake class.
    • more than one → left untouched in the shared solve, and reported as AmbiguousReferenceBus. This verdict is decided once, up front, and is never later overwritten by a numerically-convergent-looking mismatch check — for the reason §3 gives.
  3. One shared solve (via whichever JacobianBackend the caller picked) across the whole (now correctly-classified) bus set.
  4. solver::finish_island_reports recovers each component's own status after the shared solve, restricting the same power_injections/effective_injection mismatch computation newton_raphson_cached's own convergence check already uses to just that component's bus indices.

outerloop::ReactiveLimits composes on top the same way it always did: each of its outer PV→PQ-switching passes calls PersistentSolver::solve (now returning Vec<IslandReport>) and returns that same Vec<IslandReport> — from its last pass once Q-limits have stabilized, or immediately if some island's own status is Singular/ MaxIterationsReached.

PowerFlowReport and IslandStatus

#![allow(unused)]
fn main() {
pub struct PowerFlowReport {
    pub buses: Vec<Bus>,
    pub islands: Vec<solver::IslandReport>,
    /// Iteration count and per-iteration convergence trace. The solve loop
    /// itself prints nothing — see `solver::SolveStats`.
    pub stats: solver::SolveStats,
}

pub enum IslandStatus {
    Converged,
    MaxIterationsReached,
    Singular,
    NoReferenceBus,        // no Slack bus at all — placeholder values only, never solved
    AmbiguousReferenceBus, // more than one Slack bus — solved, but not necessarily meaningful
}
}

A real limitation, stated plainly rather than papered over

Singular cannot, in general, be attributed to a specific island — the shared-solve caveat from §4, concretely. Every backend solves one combined sparse factorization, and singularity is detected via a finiteness check on the result vector (sparse.rs), not a specific failing pivot or row. When the overall solve reports Singular, every component whose own post-hoc mismatch is still above tolerance gets marked Singular too — best-effort, not precise. A component whose own mismatch is already below tolerance is marked Converged regardless of the overall status, since every backend returns Singular before applying that iteration's update (confirmed directly in both the Scalar and Block backends), so buses always holds the last fully-applied, fully-finite state — never a partially-updated or NaN-poisoned one — making this post-hoc check meaningful even after an overall Singular result.

One existing "sourceless placeholder" precedent

cgmes.rs's de-energized-bus block — uses CGMES's own TopologicalIsland membership (a semantic guarantee from the standard: "only energised TopologicalNode-s shall be part of the topological island"), which encodes real domain knowledge the raw admittance graph doesn't have — not just a bypass-caller accommodation, unlike the PGM case above. Kept unconditionally: per the FullGrid diagnosis in Motivation, if the generic pass replaced this block, FullGrid's real converter gap (EquivalentBranch unimplemented) would get silently repackaged as 4 spurious NoReferenceBus islands instead of surfacing as the connectivity bug it actually is.

The generic pass layers on top of CGMES's own block, unconditionally, for every entry point — a harmless, idempotent no-op for input that's already de-energization-resolved by CGMES's own TopologicalIsland logic, and the mechanism that gives CGMES richer per-island reporting once a file genuinely declares more than one TopologicalIsland.

Validated

tests/multi_island_test.rs covers, entirely through the public API (no internal-only test module needed, since PersistentSolver::solve is itself public and does the full partitioning/reporting internally): two well-formed islands matching independent single-island solves exactly; a no-slack island correctly placeholder-reported without poisoning a well-formed neighbor (the literal repro of the original bug); an ambiguous (two-slack) island likewise not poisoning a neighbor; all three non-trivial statuses coexisting in one call, in connected_components's bus-index-ascending order; and an easy-converging island correctly reported Converged even while a deliberately hard one drags the overall solve to MaxIterationsReached — exercised by calling PersistentSolver::solve directly with a custom low max_iter, since run_power_flow_analysis_from_ybus's own tol/max_iter are fixed.

Tool reference

ToolIsland definition (§1)Which are solved (§2)Sourceless component (§3)
gridoxideconnected == synchronous (no DC edges in the graph)all, unconditionally, in one shared solve; per-island IslandReport/IslandStatus. batch::BatchSolver inherits this per scenario; bde::solve_batch_block_diagonal deliberately does not partition at all (see "The pipeline")zero-voltage placeholder, reported NoReferenceBus
power-grid-modelconnected == synchronous (no DC/HVDC modeling at all)all, unconditionally — DFS from every Source builds a separate internal "math model" per reachable sub-graph (topology.hpp: "divide grid into several math models... start search from a source")null/zero output (get_null_output()), rest of the calculation unaffected
powsybl-core / powsybl-open-loadflowboth, distinctly: AbstractConnectedComponentsManager traverses AC + DC/HVDC, AbstractSynchronousComponentsManager AC onlyopt-in via LoadFlowParameters.ComponentMode: MAIN_CONNECTED (default), ALL_CONNECTED, MAIN_SYNCHRONOUS. Under ALL_CONNECTED, LfNetworkLoaderImpl.load() groups buses by (connectedComponentNum, synchronousComponentNum) and builds one independent LfNetwork per group, each with its own slack selection and its own LoadFlowComponentResultdropped under the default MAIN_CONNECTED — this is why pypowsybl's bus counts run below gridoxide's on every CGMES fixture

Neither tool auto-detects a mode from the network's actual shape: powsybl's DEFAULT_COMPONENT_MODE is a fixed constant, and PGM has no mode at all. The classification itself always runs unconditionally; only selection is ever configurable.

Continuation Power Flow

Motivation

Every other method in this book answers what is the state, given these injections. Continuation answers a different question: how much further can this network be loaded before the voltage collapses?

An ordinary Newton solve cannot answer it, and the reason is worth being precise about. Approaching the collapse point, the power-flow Jacobian becomes singular, so Newton stops converging. But gridoxide — like every solver in its comparison set — detects singularity only as a non-finite result vector (sparse::RealSparseSystem::solve_values); there is no pivot magnitude, determinant or condition estimate anywhere in the tree. So "the Jacobian went singular" and "the initial guess was bad" look identical from the outside. Ramping load with ordinary solves brackets the limit from below and never actually finds it.

Continuation reformulates the problem so that the singular point is a regular point of a larger system, and walks the solution curve straight through it.

The formulation

The Newton-Raphson solve drives

\[ g(x) = s_{calc}(x) - s_{spec} = 0 \]

with \(x\) holding every non-slack bus's angle followed by every PQ bus's voltage magnitude. Continuation parameterizes the specification by a scalar \(\lambda\):

\[ s_{spec}(\lambda) = s_{base} + \lambda,\Delta s, \qquad g(x, \lambda) = s_{calc}(x) - s_{spec}(\lambda) = 0 \]

so that

\[ \frac{\partial g}{\partial x} = J, \qquad \frac{\partial g}{\partial \lambda} = -\Delta s \]

\(\lambda = 0\) is the base case and \(\lambda = 1\) is the base case plus one full direction. That is \(n\) equations in \(n+1\) unknowns; one more equation — the parametrization \(p(x,\lambda) = 0\) — closes it.

The corrector

One extended Newton step on \([g;\ p] = 0\):

\[ \begin{bmatrix} J & -\Delta s \\ \partial p/\partial x & \partial p/\partial \lambda \end{bmatrix} \begin{bmatrix} \Delta x \\ \Delta\lambda \end

\begin{bmatrix} \mathrm{mismatch}(x,\lambda) \\ -p(x,\lambda) \end{bmatrix} \]

With \(\Delta\lambda\) pinned to zero this reduces, term for term, to the ordinary Newton step — same unknown ordering, same mismatch, same convergence test. That equivalence is asserted directly in the test suite.

The predictor

The same matrix, with right-hand side \([0;\ 1]\), normalized to unit length:

\[ \begin{bmatrix} J & -\Delta s \\ b^{T} & d \end{bmatrix} \begin{bmatrix} t_x \\ t_\lambda \end{bmatrix} = \begin{bmatrix} 0 \\ 1 \end{bmatrix}, \qquad z^{p} = z_0 + \sigma, t \]

The parametrization row

Variant\(p(x,\lambda)\)\([b^{T} \mid d]\)
Natural\(\lambda - \lambda^{p}\)\(e_n^{T}\)
Local (default)\(z_k - z_k^{p}\), \(k = \arg\max|t_i|\)\(e_k^{T}\)
PseudoArcLength\(t^{T}(z - z_0) - \sigma\)\([t_x^{T} \mid t_\lambda]\) — dense

Why the bordered matrix is not singular at the nose

This is the point of the whole construction, and it is the thing to understand before anything else.

At a simple fold, \(J\) has a one-dimensional null space \(\mathrm{span}(v)\), and transversality gives \(-\Delta s \notin \mathrm{range}(J)\). The bordered matrix is then non-singular exactly when \(b^{T}v \neq 0\).

  • For local parametrization, \(b = e_k\) with \(k = \arg\max|t_i| = \arg\max|v_i|\) — so \(b^{T}v = v_k \neq 0\), and it is the largest entry of \(v\), so not merely nonzero but as far from zero as any entry gets.
  • For pseudo-arclength, \(b = t_x\), and at the nose \(t = (v, 0)\) — so \(b^{T}v = \lVert v\rVert^{2} \neq 0\).

Either way the augmented system stays perfectly well posed at the very point where the power-flow Jacobian does not.

This also rules out the cheaper-looking alternative. One could keep the plain \(n \times n\) Jacobian, reuse its factorization and recover \(\Delta\lambda\) by block elimination — two solves plus a scalar. That fails precisely where continuation earns its keep: the Schur complement \(d - b^{T}J^{-1}c\) is a quotient of two quantities that both vanish at the nose, and with no condition estimate available there is no way to detect the transition either.

What the border costs — measured, and it decides the default

The \(\lambda\) column is nearly free. COLAMD's dense_col threshold trips on it, the column is ordered last, and the factors are then exactly the bordered factorization. Measured on case1354pegase (\(n = 2449\)), a bordered solve with a sparse border row costs 1.4× a plain Newton solve.

The border row is not free, and the reasoning that says it should be is wrong. Emitting it densely — which pseudo-arclength genuinely requires, since its row is the previous tangent — costs 92× the plain solve at the same size, while the symbolic analysis stays at 1.0×. So this is numeric fill during factorization, not a bad ordering, and no argument about COLAMD's dense-row handling makes it go away. At 119 buses the same comparison is 1.7×, which is exactly why the problem is invisible on small fixtures and why it was found by measuring rather than by reading.

Hence Local is the default: its row is the single entry \(e_k\), and it carries the same transversality guarantee shown above. The continuation index \(k\) is part of the sparsity pattern, so a change to it costs one re-analysis — but \(k\) is stable, because far from the nose the tangent is dominated by \(\lambda\) (so \(k = n\)) and near it a single voltage component takes over. One index is pinned for a whole step: the tangent solve, the corrector, and every event-locator trial all share it, so one symbolic factorization serves them all.

End to end, case1354pegase traces to its nose in 1.4 s and case9241pegase in 10 s (156 points, 424 bordered solves) on one core.

Two consequences are stated rather than hidden:

  • JacobianBackend::Block is refused. Its 2×2-per-bus structure has no home for a scalar \(\lambda\) unknown, so run_continuation returns BackendUnsupported instead of quietly substituting another backend.
  • BTF is defeated by the border, so the KLU backends lose any block-triangular split. Negligible on connected transmission networks; real on multi-island ones. KluNative, the default, is one of them; Scalar is unaffected.

Two kinds of limit, and why the difference matters

|V|                                          |V|
 |    ___                                     |    ___
 |   /   \___                                 |   /   \___
 |  /        \__                              |  /        \__
 | /            \  <- saddle-node:            | /          X   <- limit-induced:
 |/               J is singular               |/               a machine saturated,
 +------------------ λ                        +------------------ λ    J is not singular

A saddle-node bifurcation is the smooth fold: \(d\lambda/d\sigma\) passes through zero and the Jacobian is singular there.

A limit-induced bifurcation is not. A generator hits its reactive limit, stops holding its voltage, and the maximum is that point — the Jacobian is perfectly well conditioned there. A continuation that only watches for \(d\lambda/d\sigma = 0\) walks straight past it, down a branch no operating point can reach, and reports a limit that is not one. gridoxide checks the re-seeded tangent after every reactive-limit event and reports CriticalPointKind::LimitInduced when it points downward in \(\lambda\). pglib_opf_case118_ieee is such a case.

Reactive limits, and why the outer-loop driver is not reused

Outer loops exist to decide a solver input from a solved state, and reactive limits are one of them. Continuation cannot use that driver, for two reasons. solve_with_loops drives an ordinary \(n \times n\) Newton and cannot solve the bordered system at all; and its fixed point flips a bus the moment a step overshoots a limit, which is exactly the imprecision that has to be removed.

So the corrector runs with bus types frozen, and the crossing is located: bracket the step in arclength, re-predict and re-correct at each trial, and narrow with Illinois-damped regula falsi until the machine sits on its limit. Every trial is a genuine solution of the power flow, so the located point is a real point on the curve rather than an interpolation between two.

What is not duplicated is the switching rule. ReactiveLimits::check remains the only code in the crate that flips a bus type and pins q_spec to a limit; continuation calls it once, at the located crossing. Measured against a brute-force bisection that uses nothing but run_power_flow, located events land within \(10^{-6}\) in \(\lambda\); switching at step granularity instead is off by \(10^{-2}\) to \(10^{-1}\).

Two loops are handled differently again. Distributed slack and area interchange move p_spec as a function of the solved state — a dependence invisible to \(\partial g/\partial\lambda\), so running them inside the corrector would silently corrupt the tangent, the one vector continuation needs exact. Their effect is exactly linear in \(\lambda\), so they are folded into \(\Delta s\) analytically instead, via LoadingDirection::scale_loads_with_pickup. Tap control is refused outright: a tap move is a discrete change to Y-bus values and would need its own event function.

The loading direction

\(\lambda_{max}\) is a property of the direction, not of the network. Two studies that stress different buses get different noses and neither is wrong, so the direction is an explicit input:

ConstructorScenario
scale_loadsevery net consumer grows at constant power factor; the slack picks it up
scale_busesonly the named buses grow — a zonal stress
scale_loads_with_pickupthe pickup is shared over named generators instead of the slack
to_target / explicita direction from a scenario or market tool

It is captured once, after the base solve, and never regenerated. Both halves matter. A sourceless island has already been zeroed by mark_unreferenced_islands, so it gets a zero direction for free. And a machine that ReactiveLimits clamped has had its q_spec overwritten with the limit — if the direction is not frozen there too, every later step ramps it straight back off the limit. The curve still converges at every step, so nothing looks wrong; the reactive limit is simply not enforced any more. That failure is invisible to any check except an independent one of where each machine actually saturates.

ZIP loads are refused

network::effective_injection adds voltage-dependent terms at the current \(|V|\), but neither JacobianPattern::fill nor its reference oracle carries a \(\partial s_{eff}/\partial |V|\) term for them. An ordinary solve survives that — the mismatch is exact, so only the step direction is approximate. Continuation does not: the Jacobian's singularity is the answer, so a missing term puts the nose in the wrong place while the curve still looks entirely plausible. run_continuation therefore returns ZipTermsUnsupported unless allow_zip is set, which attaches a warning instead.

A two-bus nose, in closed form

On two buses the solvability boundary can be written down exactly, which is what the primary test gate checks against. Slack at \(E\angle 0\), a line \((r, x)\), one constant-power load. Eliminating the angle from the two injection equations, with \(a = rP + xQ\), \(b = xP - rQ\) and \(u = |V_2|^2\):

\[ u^{2} - (2a + E^{2}),u + (a^{2} + b^{2}) = 0, \qquad D = 4aE^{2} + E^{4} - 4b^{2} \]

The nose is \(D = 0\). Substituting \(a = \mu a_0\), \(b = \mu b_0\) with \(\mu = 1+\lambda\) leaves a quadratic in \(\mu\):

\[ \lambda_{max} = \frac{E^{2}\left(a_0 + \sqrt{a_0^{2} + b_0^{2}}\right)}{2,b_0^{2}} - 1, \qquad |V_2|{nose} = \sqrt{\tfrac{1}{2}\left(2\mu{max}a_0 + E^{2}\right)} \]

For a lossless line at unity power factor this collapses to the textbook \(P_{max} = E^2/2x\) with \(|V| = E/\sqrt 2\). The formula holds including resistance and at any power factor, leading or lagging, so it gates the solver against arithmetic rather than against itself.

Against other tools

gridoxideMATPOWER runcpfPSATpower-grid-model / pypowsybl / pandapower
Predictortangenttangent or secanttangent— none implements continuation
Parametrizationnatural / local / pseudo-arclengthsame three, arclength by defaultsame three—
Reactive limitslocated exactly, by bisection on arclengthevent functions, locatedyes—
Limit-induced bifurcationdetected and reported as a distinct kinddetecteddetected—
Lower branchtracedtracedtraced—
Weakest busright tangent at the nose, rankedavailableparticipation factors—

None of the five reference implementations vendored under references/ has any continuation code, which is why the gates in tests/continuation_test.rs lean on a closed form and on independent re-checks rather than on a reference to diff against.

Using it

gridoxide continuation network.json --enforce-q-limits
gridoxide continuation network.json --enforce-q-limits --curve      # the whole walk
gridoxide continuation network.json --target-lambda 0.5             # stop short
gridoxide continuation network.json --lower-branch                  # past the nose and back
gridoxide continuation network.json --buses 12,13 --pickup 1,2      # a zonal stress
import gridoxide

curve = gridoxide.continuation("network.json", enforce_q_limits=True)
print(curve.lambda_max, curve.critical_kind, curve.margin_mw)
for event in curve.events:
    print(f"bus {event.bus} hit q_{event.limit} at lambda {event.lambda_:.6f}")
#![allow(unused)]
fn main() {
use gridoxide::continuation::{run_continuation, ContinuationOptions, LoadingDirection};

let direction = LoadingDirection::scale_loads(&buses);
let curve = run_continuation(
    buses, &lines, &transformers, &shunts,
    ContinuationOptions { direction, ..Default::default() },
);
}

Limitations

  • PV → PQ is one-directional, inherited from ReactiveLimits. A machine that comes back into range on the lower branch stays clamped, so lower-branch results are conservative.
  • Multi-island: one \(\lambda\) and one parametrization row couple every island, so \(\lambda_{max}\) is the first island to collapse and the weakest-bus ranking describes only that island.
  • Tap changers do not move during a trace.
  • A degenerate bifurcation — transversality failure, a pitchfork — makes even the bordered matrix singular. The backend returns nothing and the status is Singular: honest, but uninformative.
  • The margin in MW is reporting only. The solver model carries no system base and Bus::u_rated is a voltage base, so base_mva is the caller's to state.

Q-V Curves and the Reactive Margin

Motivation

Continuation power flow asks how much further the system can be loaded before it collapses. This asks a narrower and more operational question about one bus: how much reactive support would it take to hold this bus at a given voltage, and how much margin is there before that stops being possible?

It is the question a reactive-planning study actually asks. A bus with 200 MVAr of margin can absorb a contingency; one with 20 cannot, and no amount of system-wide loadability tells you which is which.

The method

Put a fictitious synchronous condenser at the bus — an ideal machine that holds a voltage and produces whatever reactive power that takes — by retyping the bus to PV. Then sweep its setpoint downward and read the reactive power it has to supply at each step:

  Q
  |    \
  |     \___                  Q > 0: the bus must be supported to hold it here
  |         \___
  +-------------\----------------------------- |V|
  |              \___                 the bus's own solved voltage: Q = 0,
  |                  \___             a condenser here has nothing to do
  |                      \__.--
  |                          ^
  |                      the nose: dQ/d|V| = 0.
  |                      No setpoint needs less reactive power than this,
  |                      and −Q there is the margin.

Why this needs no continuation

Every point is an ordinary power flow, and fixing \(|V|\) is what makes it so. The bus's reactive equation is dropped and its magnitude stops being an unknown — which is exactly a PV bus — so the Jacobian stays non-singular all the way through the nose.

A P-V curve needs a predictor–corrector precisely because it does not fix a magnitude: λ is a free parameter and the Jacobian goes singular at the fold. Here the fold is in \(Q\), which is an output rather than an unknown, so nothing goes singular and a sweep is enough.

That is a difference in kind, not an implementation shortcut, and it is why this reuses run_power_flow rather than continuation's bordered corrector.

Interpolating the minimum

The nose falls between samples, and pinning it to the nearest one wastes accuracy the sweep already paid for. A parabola through the three bracketing samples is exact where the curve is locally quadratic, which it is at a smooth minimum, and costs no extra solves. Measured on the two-bus case with samples 0.01 apart, the interpolated nose lands within \(4 \times 10^{-6}\) of the analytic voltage where the nearest sample can be out by 0.005.

The vertex is refused if it falls outside the bracketing samples — that means the three points do not describe a minimum and the parabola is extrapolating.

A curve in closed form

On two buses the whole curve can be written down, which is what the solver is gated against. Slack at \(E\angle 0\), a lossless line \(jx\), one bus with net injection \(P + jQ\). Eliminating the angle gives

\[ u^{2} - (2a + E^{2})u + (a^{2} + b^{2}) = 0, \qquad a = xQ,\ b = xP,\ u = |V|^{2} \]

Read as a quadratic in \(Q\) instead of in \(u\), the operating branch is

\[ Q(u) = \frac{u - \sqrt{E^{2}u - x^{2}P^{2}}}{x} \]

and \(dQ/du = 0\) gives the nose directly:

\[ u_{nose} = \frac{E^{4} + 4x^{2}P^{2}}{4E^{2}}, \qquad Q_{nose} = \frac{4x^{2}P^{2} - E^{4}}{4E^{2}x} \]

tests/qv_test.rs matches both to \(10^{-5}\) in \(Q\) across a range of reactances and loadings — the solver checked against arithmetic rather than against itself.

Q-V and P-V do not agree on the weakest bus

This is worth stating plainly, because the opposite is the natural assumption and it is false.

Both are voltage-stability measures, so it is tempting to expect the bus a P-V trace names critical — the largest component of the tangent at the nose — to be the bus with the smallest Q-V margin. Measured on case14:

ranking, weakest first
P-V (continuation tangent at the nose)4, 3, 8, 9, 6
Q-V (reactive margin at base loading)7, 13, 5, 9, 11

One bus in common out of five. The obvious explanation — that they are evaluated at different operating points, one at the nose and one at base loading — does not rescue it either: re-running the Q-V sweep at 98% of \(\lambda_{max}\) makes the agreement worse, not better.

They measure related but distinct things. A P-V critical bus is a property of the system-wide collapse mode along one particular loading direction. A Q-V margin is one bus's local reactive headroom at the current operating point. A bus can be locally weak without participating much in the system's collapse mode, and the reverse.

This is why utilities run both rather than either, and why tests/qv_test.rs deliberately does not assert that they agree — a test to that effect would have been asserting something untrue about the physics.

Reading the result

  • The margin is a positive number of MVAr, and larger is stronger.
  • nose_not_reached means the sweep stopped while the curve was still falling. The number is then a lower bound on the margin, not the margin, and the CLI says so — understating a bus's weakness is the direction that misleads.
  • already_controlled means the bus was already PV, so the curve describes moving an existing machine's setpoint rather than adding a condenser, and \(Q\) includes what that machine was already producing.
  • The condenser is deliberately unlimited. The curve measures how much reactive power holding a voltage would take, which a limit would truncate rather than answer. Other buses keep their own limits.

Using it

gridoxide qv network.json --weakest 10          # rank buses by margin
gridoxide qv network.json --bus 13 --curve      # one bus, with the curve
gridoxide qv network.json --bus 13 --v-min 0.2  # sweep further down
curve = model.qv_curve(13, v_min=0.20)
print(curve["status"], curve["nose"]["margin_pu"])
for p in curve["points"]:
    print(p["voltage"], p["q"])
#![allow(unused)]
fn main() {
use gridoxide::qv::{qv_curve, QvOptions};

let curve = qv_curve(&buses, &lines, &transformers, &shunts, 13, QvOptions::default());
if let Some(nose) = &curve.nose {
    println!("{:.1} MVAr at |V| = {:.4}", nose.margin_mvar(100.0), nose.voltage);
}
}

Limitations

  • One bus at a time. A ranking is the caller looping, and each point is a full solve, so a sweep over every bus of a large network is buses × steps power flows. Embarrassingly parallel, and not parallelized.
  • The margin is measured at one operating point. It says nothing about the margin after a contingency, which is the question a security study asks — that would be this sweep inside a contingency loop.
  • A slack bus is refused: it already fixes its own magnitude and already has a free reactive injection, so there is no condenser to add.

AC Sensitivity Analysis

Motivation

A power flow answers what is the state of the network? Most operational questions are one step past that: what happens if something changes?

  • This generator is about to ramp 50 MW. Which line picks it up?
  • This corridor is at 98% of its limit. Which injection relieves it fastest, and by how much per MW?
  • The voltage at this bus is sagging. Would a tap change help, or does it need reactive support?

Each is a partial derivative of the solved operating point with respect to an input. You could answer any of them by re-solving — perturb, solve again, subtract — and for one question that is perfectly reasonable. It stops being reasonable at a hundred questions, and it is unnecessary: the derivative is available in closed form from a factorization the solve already computed.

The formulation

A converged power flow satisfies

\[ g(x, p) = s_{calc}(x) - s_{spec}(p) = 0 \]

where \(x = [\theta_{non\text{-}slack};\ |V|_{PQ}]\) is exactly the unknown vector Newton iterates on, and \(p\) is whatever input we are differentiating with respect to. Differentiating the identity and rearranging:

\[ \frac{dx}{dp} = -J^{-1} \frac{\partial g}{\partial p} \]

with \(J = \partial g / \partial x\) — the same Jacobian Newton already builds, filled at the converged state. Nothing new is assembled.

For an injection the right-hand side is especially simple. A specified injection enters exactly one equation, so \(\partial g/\partial p = -e_i\) and

\[ \frac{dx}{dp_i} = J^{-1} e_i \]

— a single triangular solve with a unit right-hand side. That is the entire method for the common case.

Any quantity of interest \(f\) then follows by the chain rule:

\[ \frac{df}{dp} = \left(\frac{\partial f}{\partial x}\right)^{\mathsf T} \frac{dx}{dp} + \left.\frac{\partial f}{\partial p}\right|_{x} \]

For a branch flow, \(\partial f/\partial x\) is already computed by branch_flow::terminal_flow_derivs — written originally for the state estimator, reused here unchanged. The second term is zero unless \(p\) is the branch's own tap; see below.

Two directions

The expression above can be bracketed either way, and the two groupings cost very differently:

SolveOne solve gives youAsk when
Forward\(J z = e_i\), per variableevery function's response to that variablefew things move, many are watched
Adjoint\(J^{\mathsf T} w = \partial f/\partial x\), per functionthat function's response to every variableone thing is watched, many could move

Forward is "this generator ramps — what happens to all 5,000 branches?" Adjoint is "this line is overloaded — which of 500 injections relieves it?" Neither is derivable cheaply from the other, so both are offered: state_response/branch_response and function_row.

Both run against one factorization, taken once in AcSensitivity::new. sparse::RealFactorization::solve_transpose reuses the same LU for the transposed direction rather than factorizing \(J^{\mathsf T}\) separately — an LU = PAQ decomposition solves the transposed system by running the same triangular factors in the opposite order.

This mirrors the ptdf_column/ptdf_row pair in DC power flow, which is the same duality on the DC side.

Variables

VariableMeaningZero when
ActiveInjection(bus)\(P\) injected at a busthe bus is slack
ReactiveInjection(bus)\(Q\) injected at a busthe voltage magnitude is held (PV or slack)
TransformerRatio(branch)off-nominal ratio \(k\)the branch is a line
PhaseShift(branch)phase-shifter angle \(\alpha\)the branch is a line

The zeros are answers, not gaps. A slack bus's injection is not an input at all — it is what the solve determines — so perturbing it moves nothing, exactly as a DC PTDF column is zero at its reference bus. Likewise reactive power at a PV bus is an output of the voltage controller.

Branch indices are the crate's flat order: lines first, then transformers.

Taps and phase shifters

A tap is a different shape of variable from an injection, and gets two things wrong if treated casually.

The sign flips. An injection enters through \(s_{spec}\), so \(\partial g/\partial p = -\partial s_{spec}/\partial p\). A tap changes the branch's admittance and therefore the calculated injections at both its ends, so \(\partial g/\partial p = +\partial s_{calc}/\partial p\). The response picks up the opposite sign.

The direct term. A tapped branch's own flow depends on the tap directly, not only through the state — that \(\partial f/\partial p|_{x}\) term above. It is zero for every branch except the tapped one, where it is large.

This is not a rounding-level concern. On distribution-case, dropping it changes \(dP_8/dk\) from \(-0.276\) to \(+0.286\): a sign flip, on the branch whose tap is being adjusted, which is the branch a user is most likely to read. Every other branch stays correct, which is exactly what makes the omission dangerous — the answer looks entirely plausible.

The admittance derivatives themselves are unusually tidy. With \(tap = k,e^{j\alpha}\), and given that \(y_{ff}\) always carries \(1/k^2\), \(y_{ft}\) and \(y_{tf}\) always carry \(1/k\), and \(y_{tt}\) never depends on the tap at all:

\[ \frac{\partial y_{ff}}{\partial k} = \frac{-2,y_{ff}}{k}, \quad \frac{\partial y_{ft}}{\partial k} = \frac{-y_{ft}}{k}, \quad \frac{\partial y_{tf}}{\partial k} = \frac{-y_{tf}}{k}, \quad \frac{\partial y_{tt}}{\partial k} = 0 \]

\[ \frac{\partial y_{ft}}{\partial \alpha} = j,y_{ft}, \quad \frac{\partial y_{tf}}{\partial \alpha} = -j,y_{tf}, \quad \frac{\partial y_{ff}}{\partial \alpha} = \frac{\partial y_{tt}}{\partial \alpha} = 0 \]

Every entry is a scaling of itself, so nothing has to be rebuilt from nameplate data — and the relations hold unchanged for the half-open terminal states, where the affected entries are already zero.

Taps come out of the adjoint at no extra solve, alongside the injection sensitivities. So "which tap relieves this overload?" costs exactly what "which injection relieves it?" does.

How this differs from the DC factors

PTDF and LODF are exact for the model they describe, because the DC model is linear — a PTDF is not an approximation of DC, it is DC. These are different. The AC problem is nonlinear, so these are first-order derivatives: accurate for small perturbations and degrading as the step grows.

What they buy in exchange is everything DC discards — voltage magnitudes, reactive flows, losses. A DC PTDF cannot tell you that an injection will collapse a voltage; an AC sensitivity can.

The usual division of labour: screen thousands of cases in DC, answer precisely about the few that matter in AC.

Islands

None, and none is needed. Every island carries its own slack, so the assembled Jacobian is block-diagonal across islands and non-singular as a whole. A sourceless island contributes no rows at all, because network::mark_unreferenced_islands has already pinned its buses to Slack — so it is excluded rather than making the system singular.

This is simpler than the DC side, where DcSensitivity factorizes each island's \(B\) separately because the reduced \(B\) is built per island.

What is not here

No contingency sensitivities. An outage is a finite change in topology, not an infinitesimal change in an input, so it is not a derivative at all. The DC side handles outages with a Woodbury update (DcSensitivity::outage_flows) that has no equally cheap AC analogue; batch::BatchSolver::solve_contingencies is the AC answer, and it re-solves.

No sensitivities through the outer loops. These differentiate the power-flow equations as posed. If PV→PQ switching would trip at the perturbed point, or a tap controller would respond, the derivative does not know it — it is the derivative of the current configuration.

Validation

tests/ac_sensitivity_test.rs, against an oracle that shares no code with the sensitivity module: perturb an input, run the ordinary Newton solver to convergence, take a central difference of the solved state.

Because AC is nonlinear the finite difference is not exact, so the two constants are chosen together against each other:

  • the solve's residual noise is divided by \(2h\), so a smaller step amplifies it;
  • the central difference's own truncation error is \(O(h^2)\), so a larger step grows it.

At \(h = 10^{-4}\) and a solve tolerance of \(10^{-10}\) both land near \(10^{-7}\). Tightening the solve further does not help: the test case's residual floors at \(1.2 \times 10^{-11}\) in double precision, so asking for \(10^{-12}\) simply never converges.

Tap sensitivities are checked the same way, by actually changing a Transformer's tap and re-solving, so the whole downstream chain sees it as a real tap change. Forward and adjoint are additionally checked against each other to \(10^{-9}\) — the same scalar bracketed the other way, so any disagreement is a transpose or indexing error rather than a modelling choice.

Using it

From the CLI — forward, then adjoint:

gridoxide sensitivity network.json --dp 2
gridoxide sensitivity network.json --watch 8

--dp/--dq take a bus, --dk/--dalpha a branch, --watch a branch to watch; --terminal from|to picks the end. It refuses to differentiate a power flow that did not converge, rather than printing derivatives of nothing.

From Rust:

#![allow(unused)]
fn main() {
use gridoxide::ac_sensitivity::{AcSensitivity, Function, Variable};
use gridoxide::branch_flow::Terminal;

let sens = AcSensitivity::new(&solved_buses, &ybus, &lines, &transformers)
    .expect("nonsingular at a converged point");

// Forward: bus 2 ramps — what does every branch do?
let flows = sens.branch_response(Variable::ActiveInjection(2), Terminal::From).unwrap();

// Adjoint: branch 8 is loaded — what moves it?
let row = sens
    .function_row(Function::BranchActivePower { branch: 8, terminal: Terminal::From })
    .unwrap();
let (best_bus, _) = row
    .d_active
    .iter()
    .enumerate()
    .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
    .unwrap();
}

From Python:

import gridoxide

s = gridoxide.AcSensitivityModel("network.json")

col = s.column(active_injection=2)   # bus 2 ramps — what responds?
row = s.row(branch=8)                # branch 8 is loaded — what moves it?

# Which tap has the most leverage on branch 8?
best = max(range(s.n_branches), key=lambda b: abs(row.d_transformer_ratio[b]))

AcSensitivityModel is a class rather than a function because there is genuinely something worth keeping between calls: one factorization, arbitrarily many questions.

Second derivatives

ac_sensitivity differentiates the operating point once. injection_hessian differentiates it twice, and exists for one reason: any interior-point method for AC-OPF needs the Hessian of the Lagrangian,

\[ \nabla^2_{xx} L = \nabla^2 f + \sum_i \lambda_i \nabla^2 P_i + \sum_i \mu_i \nabla^2 Q_i \]

whose objective term is trivial and whose constraint terms are these.

Three design choices are worth stating, because each is the opposite of what the power-flow code does and each has a reason.

The weighted sum, never one \(\nabla^2 P_i\) at a time. Each individual Hessian is a sparse \(2n \times 2n\) matrix and there are \(2n\) of them, so materializing them separately would cost \(O(n)\) matrices to build one. Accumulating as we go makes it a single pass over the Y-bus — the same cost as assembling the Jacobian.

The full \(2n\) state, not the reduced one. Newton drops the slack angle and every PV magnitude, because it solves with bus types fixed. AC-OPF makes generator voltages decision variables, so the rows Newton discards are exactly the ones the optimizer needs. Emitting everything and letting the caller select is both more general and easier to check.

No fill beyond the Jacobian. This is not obvious and it is what keeps AC-OPF tractable: \(\nabla^2 P_i\) has no entry coupling two distinct neighbours of bus \(i\), even though both appear in \(P_i\). Each term of \(P_i\) involves exactly one neighbour, so differentiating twice can never bring two together. A second-order method therefore needs no more sparsity budget than a first-order one.

How it is checked

Hessians are hard to test — hand-deriving them twice tends to reproduce the same mistake twice — so the validation is deliberately two-staged, with each stage grounded outside the code it tests.

  1. A second Jacobian, checked against the first. injection_jacobian computes the same first derivatives as jacobian::JacobianPattern by a different route: summing over neighbours directly, where the power-flow assembler reads its diagonal blocks out of precomputed injections (H_ii = −Q_i − V_i²B_ii). They share no code, so the mature assembler serves as an independent oracle. On case118_ieee that is over a thousand entries agreeing to 1e-9.
  2. The Hessian, central-differenced against that verified Jacobian. Since stage one established the Jacobian independently, this grounds the second derivatives in something outside their own implementation.

Doing it in one step — second-order differences of the injections themselves — would work in principle and be much weaker in practice. A second difference has an error floor of \(O(h^2 + \epsilon/h^2)\), leaving roughly six usable digits where a first difference of an exact Jacobian gives ten.

The Optimal Power Flow Problem

Motivation

A power flow answers what is the state of the network, given who is generating what? Optimal power flow turns that around: who should generate what, so that demand is met at least cost without overloading anything?

That inversion changes the character of the problem. A power flow has one answer, and Newton either finds it or does not converge. An OPF has a feasible set — every dispatch that serves demand within limits — and asks for the cheapest point in it. The output is not just a state but a decision, and alongside it a set of prices that say what each constraint is costing.

Those prices are usually the reason to run it:

  • Locational marginal price — what one more MW of demand at this bus would cost. Uniform across the network when nothing is congested; the spread is the congestion, in dollars.
  • Shadow price of a branch limit — what one more MW of capacity on that corridor would save per hour. This is the number that justifies a reinforcement.

gridoxide implements both DC-OPF and AC-OPF, and the difference between them is not just accuracy. DC is a convex quadratic program, so its answer is provably optimal. AC is nonconvex, so its answer is a local optimum satisfying the first-order conditions — which is the state of the art, and what every published AC-OPF objective means. That distinction shapes how each is validated.

The formulation

Minimize generation cost over dispatch \(P_g\), bus angles \(\theta\), and load shedding \(s\):

\[ \min_{P_g,\ \theta,\ s}\ \sum_g \left(c_{2g}P_g^2 + c_{1g}P_g + c_{0g}\right)

  • \sum_d \pi, s_d \]

subject to, at every bus \(i\), the DC power balance

\[ \sum_{g \in i} P_g + s_i - (B\theta)_i = P^{load}_i - c_i \]

plus the generation box \(P^{min}_g \le P_g \le P^{max}_g\), the shedding box \(0 \le s_d \le P^{load}_d\), the branch limits

\[ -\text{rate} \le b,(\theta_f - \theta_t - \alpha) \le \text{rate} \]

and the reference angle \(\theta_{ref} = 0\). Here \(c_i\) collects the constant contribution of any fixed phase shift \(\alpha\).

Two Buses, One Congested Line writes that program out on the smallest network that has a price spread at all, and derives the locational marginal prices and the branch limit's shadow price from its optimality conditions.

With quadratic costs this is a convex QP; with piecewise-linear costs it is an LP. Both go to the same solver through the same interface.

Piecewise-linear costs enter through an epigraph

MATPOWER's cost model 1 gives a curve the objective cannot express directly. A convex piecewise-linear function is the upper envelope of its segments, so \(\min f(p)\) becomes \(\min z\) subject to \(z \ge m_i p + k_i\) for every segment — ordinary rows, which is exactly why an LP-capable solver was a requirement rather than a preference.

Exactness needs convexity, which is checked and enforced: the epigraph of a non-convex curve is its convex envelope, which charges less than the curve does, so such a curve is refused rather than silently relaxed.

AC-OPF uses the same reformulation for a different reason. CostCurve::evaluate handles a piecewise curve correctly, so feeding it to the objective gives the right values — but the objective is then only \(C^0\), and a Newton method assumes \(C^2\). The gradient jumps at every breakpoint, and an optimum very often sits exactly on one, since a breakpoint is where marginal cost changes. The epigraph makes the objective linear and moves the kinks into constraints, which the solver handles exactly.

This was a real bug, not a hypothetical. Both formulations read costs with if let Some(CostCurve::Polynomial { .. }), so a piecewise curve fell through in silence and left the generator's objective coefficient at zero — it looked free, and the optimizer dispatched it first. Wrong dispatch, wrong cost, no error, reachable straight from the documented converter. What hid it was that every committed fixture used model 2. case5_pjm_pwl is now generated as an exactly equivalent rewrite of case5_pjm — every generator there has \(c_2 = 0\), so three collinear points reproduce the cost precisely while still producing two segments — which makes the test a question with a known answer.

The balance row is written generation-minus-outflow

The balance constraint could equally be written with load on the left. Putting it on the right is a deliberate choice about the duals rather than cosmetics: with load on the right-hand side, the row's dual is \(\partial(\text{cost})/\partial(\text{load})\) directly — which is the locational marginal price, positive, in the sign anyone reading it expects. The opposite orientation yields the negated quantity, and the resulting sign error would appear in exactly the output most likely to be published, looking plausible everywhere and being wrong everywhere.

Why \(\theta\) and not PTDF

Branch limits could be expressed against injections through a PTDF matrix, eliminating the angle variables entirely. That formulation is standard in the literature and tempting here, because gridoxide already computes PTDF for DC sensitivity.

It is rejected on density. PTDF is a full \(n_{branch} \times n_{bus}\) dense matrix — 1.19 GB on case9241pegase, as DC power flow records — whereas the \(\theta\) formulation keeps the sparsity the rest of the crate is built around. The extra angle variables are cheap; a dense constraint matrix is not. DcSensitivity stays a screening tool rather than becoming a constraint builder.

Which susceptance — and why it is not a detail

DC has two defensible ways to form branch susceptance from \(r\) and \(x\):

FormulaWho computes it
IgnoreR\(b = 1/x\)MATPOWER makeBdc, pandapower, lightsim2grid
IgnoreG\(b = x/(r^2 + x^2)\)PowerModels — i.e. \(-\mathrm{Im}(y_{series})\)

For power flow the choice is minor and gridoxide defaults to 1/x, matching the tools people cross-check against. For OPF it is not minor, because susceptance enters the constraint set and not merely the reported flows: it decides which branch reaches its limit first, and therefore which constraint binds and what everything is priced at.

This surfaced as a concrete discrepancy worth recounting, because the symptom pointed the wrong way. Measured against pglib-opf's published DC objectives:

Case\(b = 1/x\)\(b = x/(r^2+x^2)\)
case3_lmbd−0.037%−0.0001%
case5_pjm−0.0006%−0.0006%
case14_ieee+0.0013%+0.0013%
case30_ieee+0.423%−0.025%
case118_ieee+0.034%−0.013%

Four cases agreed to better than 0.04% and one was ten times worse. The natural reading — a bad limit, a missing constraint, a converter bug specific to that case — was wrong. Direct comparison against the MATPOWER file ruled out all 41 susceptances, all 41 branch limits, every per-bus load, and every generator box; the KKT certificate (below) ruled out our own solver.

The cause was the formula. case30_ieee's branch 1→2 has \(r = 0.0192,\ x = 0.0575\) — an r/x ratio high enough that \(1/x\) overstates susceptance by 10% — and that branch is congested at the optimum, so the error landed squarely on a binding constraint. Adopting the series form brought case30 inside 0.03% and improved all four other cases.

The transferable lesson: a discrepancy isolated to one case is not evidence that the fault is isolated to one case. The formula was wrong on every network; only case30 was congested on a branch resistive enough to reveal it. Matching a published objective to 0.4% was not reassurance — it was the signal.

So DC-OPF defaults to IgnoreG while DC power flow defaults to IgnoreR. The two defaults are opposite on purpose, under one consistent rule: each matches what the reference implementations in its own domain compute. Both remain selectable in either.

Which controls DC admits

Of the four control families an OPF might optimize, DC admits two:

ControlStatusWhy
Generator active power✅The decision variable.
Load shedding✅Linear, priced at shed_price.
Phase-shifter angle⚠️ read, held fixedLinear in \(\theta\), so this is a small extension the module is shaped for — but not yet taken. Shifts enter as constants.
Transformer tap ratio❌DC uses \(b/k\), so making \(k\) a variable is nonconvex and would forfeit the guarantee that is the entire reason to do DC first.
Reactive power / voltage❌Meaningless in DC, which has no reactive power and holds \(

Taps and reactive controls arrive with AC-OPF.

Load shedding

Shedding is offered by default, priced at \$10,000/MWh — far above any plausible generator marginal cost, so it is a last resort rather than an economic choice, but finite, so an otherwise-infeasible case still returns an answer that says where demand could not be served. That is almost always more useful than the word "infeasible".

Turning it off (--no-shedding) makes such a case genuinely infeasible, which is sometimes the question being asked.

The solver boundary

OPF modelling never touches an optimizer directly. Everything crosses a small in-crate interface:

#![allow(unused)]
fn main() {
pub struct LinearProgram {
    pub n_vars: usize,
    pub col_lower: Vec<f64>,
    pub col_upper: Vec<f64>,
    pub col_cost: Vec<f64>,
    pub offset: f64,
    /// Lower triangle of the objective Hessian, `(i, j, value)` with `i >= j`.
    /// `None` for a pure LP.
    pub hessian: Option<Vec<(usize, usize, f64)>>,
    /// Constraint matrix as `(row, col, value)` triplets.
    pub rows: Vec<(usize, usize, f64)>,
    pub row_lower: Vec<f64>,
    pub row_upper: Vec<f64>,
}
}

Two-sided row bounds express everything DC-OPF needs uniformly: an equality is lower == upper (the balance rows), a range is -rate ≤ flow ≤ rate, and a one-sided limit sets the other side infinite. Triplets rather than a compressed format because that is the shape the rest of the crate already speaks; the backend converts internally, where the conversion is tested once.

There are two backends behind that interface, and having two is deliberate.

The default is gridoxide's own interior-point method (opf::ipm) — Mehrotra predictor-corrector on the sparse factorization this crate already owns. Pure Rust, no system libraries, nothing to install, so it is built and tested in CI along with everything else.

HiGHS is the reference, reached through gridoxide's own bindgen-generated FFI rather than a third-party wrapper crate — the same approach the KLU and PARDISO backends take, and for the same reason. It needs the opf-highs feature and a local HiGHS install (libhighs-dev on Debian/Ubuntu), so it is not exercised by CI. Nothing depends on it; it is selected with --highs or solver="highs".

One deliberate non-choice worth recording: build.rs does not use pkg-config, even though HiGHS ships a .pc file. Ubuntu's is broken — every path in it carries a doubled /usr prefix. Discovery uses standard paths with a HIGHS_ROOT override instead.

Why keep both

Because two independent solvers are a validation gate. On a convex problem the optimal objective is unique, so a disagreement between them is a bug in one — not a modelling convention, not a different local optimum, not a tolerance question. Across the pglib fixtures they agree to 1.1e-11 relative; tests/opf_cross_test.rs also runs 300 randomly generated convex QPs through both.

That randomized comparison earned its keep immediately. It found a real defect in the interior-point method that no fixture exposed: the solver was taking different primal and dual step lengths, which is standard and strictly better for a linear program but silently breaks a quadratic one. Substituting the Newton step into the dual residual after a split step leaves

\[ r_d \leftarrow (1 - \alpha_d) r_d + (\alpha_p - \alpha_d) Q \Delta u \]

The first term is the contraction the method depends on; the second is pure error, vanishing only when \(Q = 0\) or the step lengths agree. Left in, the iterates settle into a limit cycle — one generated QP repeated with period 8 until the iteration limit while HiGHS solved it without trouble. Forcing a common step length whenever the objective is quadratic fixes it.

What the cross-check does not do is establish correctness. Both backends receive the same LinearProgram. If the model is assembled wrongly, both agree perfectly on the wrong answer — which is exactly what would have happened with the susceptance bug above. Cross-validation tests the solvers; the published objectives test the model.

Validation

DC-OPF is checked three ways, deliberately independent:

1. Analytic cases. Small networks whose optimum is derivable on paper — an uncongested two-bus case that must dispatch the cheap unit only, a congested one whose price split is known in closed form, and shedding cases with and without the option enabled.

2. KKT certificates and the second solver. For a convex problem the KKT conditions are a proof of optimality, not a comparison. Every pglib case asserts stationarity (\(\text{col\_dual} = Qx + c - A^{\mathsf T}y\)), primal and dual feasibility, and complementary slackness. This is what separates "our model differs from theirs" from "our answer is wrong" — and in the case30 investigation above, it is what made the difference diagnosable at all.

3. Published objectives. Against pglib-opf's own DC baseline, all five cases now agree to better than 0.03% — about as close as figures published to five significant digits can resolve. The measured per-case gaps are pinned in tests/opf_dc_test.rs so a regression shows up as a change rather than requiring someone to re-derive what "close enough" means.

Note what is deliberately absent: no reference solutions generated from an installed pypower or pandapower. Comparing against another implementation's output tests agreement, not correctness, and inherits its bugs silently.

Using it

CLI

gridoxide opf network.json

Costs and limits come from a companion document defaulting to <network>.opf.json — the pair gridoxide-matpower writes when converting a MATPOWER case. On case5_pjm:

5 bus(es), 5 generator(s), 1000.0 MW of demand
total cost: 17479.90 $/h

dispatch (MW):
  generator    0:     40.000 (at max)
  generator    1:    170.000 (at max)
  generator    2:    323.495
  generator    3:      0.000 (at min)
  generator    4:    466.505

locational marginal price ($/MWh):
  bus    0:    16.9774
  bus    1:    26.3845
  bus    2:    30.0000
  bus    3:    39.9427
  bus    4:    10.0000
  spread: 29.9427 (the congestion)

binding branch limits:
  branch    5: flow   -240.000 of   240.000 MW, worth   62.3220 $/MWh to relieve

Every number that makes this an OPF rather than a power flow is in the bottom two blocks: the price spread, and the single branch causing it.

--data <path> names the companion document explicitly, --no-shedding removes the shedding option, --shed-price re-prices it, --ignore-r selects the textbook \(b = 1/x\), and --highs swaps in the reference backend.

Rust

#![allow(unused)]
fn main() {
use gridoxide::linear::DcApproximation;
use gridoxide::opf::dc::{DcOpf, DcOpfOptions};
use gridoxide::opf::ipm::IpmSolver;
use gridoxide::opf::Solver;
use gridoxide::pgm::opf::dc_opf_network_from_pgm;

let network = dc_opf_network_from_pgm(input, &data, 50.0, DcApproximation::IgnoreG)?;
let opf = DcOpf::build(network, DcOpfOptions::default())?;

let mut solver = IpmSolver::new();
let result = opf.interpret(&solver.solve(opf.problem())?);

println!("{:.2} $/h", result.objective);
for b in &result.binding {
    println!("branch {} at {:.1} MW, worth {:.2} $/MWh", b.branch, b.flow, b.price);
}
}

build and solve are separate steps because the assembled LinearProgram is worth inspecting on its own — and because that is the seam a second backend plugs into.

Python

import gridoxide

r = gridoxide.dc_opf("network.json")

print(f"{r.objective:.2f} $/h")
print(f"congestion: {max(r.lmp) - min(r.lmp):.2f} $/MWh")

for b in r.binding:
    print(f"branch {b.branch}: {b.flow:.1f} of {b.rate:.1f} MW, {b.price:.2f} $/MWh to relieve")

dc_opf is a function rather than a class because — unlike AcSensitivityModel — there is nothing worth keeping between calls: one solve answers one question.

AC-OPF

The full problem: real voltage magnitudes, reactive power and losses, optimizing generator active and reactive output together.

gridoxide opf network.json --ac

What changes

Everything that made DC a convex QP goes away. The balance equations are trigonometric, so the feasible set is curved and generally nonconvex. In exchange, the two control families DC cannot express arrive: generator reactive power, and bus voltage magnitude.

DC-OPFAC-OPF
Problem classConvex QPNonconvex NLP
GuaranteeProvably global optimumLocal optimum (first-order conditions)
Variables\(P_g, \theta, s\)\(P_g, Q_g, \theta, |V|\)
Balance rows\(n\), linear\(2n\), trigonometric
Branch limits\(|b,\Delta\theta| \le \text{rate}\)\(P^2 + Q^2 \le \text{rate}^2\)
Solveropf::ipm (Mehrotra)opf::nlp (line search + regularization)

Limits are squared rather than written as \(\sqrt{P^2+Q^2}\) so that no square root — whose derivative is undefined at zero flow — ever enters the model.

What nonconvexity costs the solver

opf::nlp is a second interior-point method, not a reuse of the convex one, because three things change:

  • A line search. The step is a direction, not a destination — a full Newton step may increase the objective or worsen feasibility, so it is accepted only if it reduces a merit function trading the two off.
  • Regularization driven by failure rather than structure. In the QP, \(\gamma\) is a tiny fixed value that only makes a rank-deficient matrix factorizable. Here the Hessian of the Lagrangian can be genuinely indefinite and the Newton direction then points at a saddle, so \(\gamma\) is raised by orders of magnitude until the direction is usable.
  • A gradual barrier. The QP drives \(\mu \to 0\) as fast as Mehrotra's heuristic allows, because its model is exact. Here each barrier subproblem is solved loosely before \(\mu\) drops; going faster produces a step that is accurate for a problem nobody asked about.

Where the derivatives come from

The balance rows' first and second derivatives are injection_hessian's — that module exists for this. The branch limits need their own, and the structure turns out to be identical: a terminal flow

\[ P_{ft} = |V_f|^2 g_{self} + |V_f||V_t| (g_{mut}\cos\theta_{ft} + b_{mut}\sin\theta_{ft}) \]

is algebraically a bus injection with exactly one neighbour, with \(y_{ff}\) as the self-admittance and \(y_{ft}\) the mutual one. The same trigonometry and the same derivative rotations apply.

Validation, and two bugs the objective alone would not have found

All five pglib cases match the published AC objectives to 0.001%, at constraint violations of 1e-9 or better. Getting there took two corrections, and in both cases what identified them was not the size of the gap but which cases had one:

  1. Per-bus voltage limits. Defaulting to [0.9, 1.1] matched case3 and case5 exactly and left the other three low by 0.4–0.5%. Those three specify [0.94, 1.06] — the looser default was buying a cheaper answer that was infeasible for the real case.
  2. Bus shunts. With voltage limits fixed, exactly the three cases carrying shunt capacitors still disagreed, and the two with none matched to 0.001%. A shunt supplies reactive power for free; dropping it makes the generators supply it instead, at a cost.

The same lesson as the susceptance bug above, in a different key: when some cases agree and others do not, the question worth asking is what the disagreeing ones have in common — not how big the gap is.

Beyond the published objectives, three further checks run: the model's gradient, Jacobian and Hessian against central differences (a wrong derivative does not make a solver fail, it makes it converge confidently to the wrong point); feasibility re-derived from network::power_injections rather than read out of the solver; and prices checked against a numerical \(\partial\text{cost}/\partial\text{load}\) — which caught a sign error that produced prices of exactly the right magnitude, negated, on every bus of every case.

IPOPT as a reference

opf::nlp has a reference backend the same way opf::ipm has HiGHS: IPOPT, behind the opt-in opf-ipopt feature, reached through gridoxide's own bindgen bindings against a system install's IpStdCInterface.h. Nothing depends on it — the in-house solver is the default and needs nothing installed.

Across the pglib fixtures the two agree to 5e-9 relative, and both match the published objectives.

The comparison is weaker than the convex one, and the difference matters. On a convex problem the optimum is unique, so two solvers disagreeing means one is wrong. AC-OPF is nonconvex: two correct solvers may find different local optima and neither is at fault. Agreement is therefore strong evidence, and disagreement would be a question rather than a verdict.

What it corroborates unambiguously is the model. IPOPT consumes the same objective, Jacobian and Hessian through a completely different algorithm, so reaching the same point tests the derivative algebra from outside this crate.

Two conventions have to be translated, and both are silent when wrong — they converge to the wrong point rather than failing:

gridoxideIPOPT
Hessian\(\nabla^2 f - \sum_i y_i \nabla^2 c_i\)\(\sigma \nabla^2 f + \sum_i \lambda_i \nabla^2 c_i\)
TriangleFull symmetricLower only, each off-diagonal once

The \(\sigma\) is not decoration: IPOPT sets it to zero in its restoration phase, so the objective's own Hessian has to be separated out and rescaled rather than assumed to carry a factor of one.

A packaging trap worth knowing about. Debian's and Ubuntu's libipopt is built against MUMPS, which is built against OpenMPI. OpenMPI runs transport discovery in a load-time initializer, and where the facilities it probes for are absent that discovery hangs — so the process hangs before main, before any Rust code runs. No amount of care inside the binding can catch it; a plain C program that merely links -lipopt and calls nothing hangs identically, which is how it was pinned on the package rather than the binding. .cargo/config.toml sets OMPI_MCA_btl=self to skip the probe.

Starting points matter

A nonconvex solver returns the optimum in whichever basin it starts. On these networks every start tested reaches the same objective to 1e-6, but that is an empirical finding about these cases, not a property of the method.

It is also load-bearing for convergence. Starting each generator at its box midpoint — the obvious choice — leaves the dispatch about 23% short of demand on every pglib fixture, because p_min is usually zero. case5_pjm and case118_ieee then never recovered, stalling at the iteration limit. Distributing demand proportionally across the generators' ranges instead costs nothing and fixed both.

Reading the answer

import gridoxide

r = gridoxide.ac_opf("network.json")

print(f"{r.objective:.2f} $/h in {r.iterations} iterations")
print(f"largest violation: {r.violation:.2e} pu")   # read this with the objective
print(f"voltage range: {min(r.magnitudes):.4f} – {max(r.magnitudes):.4f} pu")

violation is part of the answer rather than diagnostics. On a nonconvex problem a lower cost at an infeasible point is not a better result, so an objective reported without it can be badly misleading.

Integrality, and which backend can honour it

LinearProgram carries a col_integral vector. Empty means every column is continuous, which is what every caller written before it existed produces, so adding it changed nothing:

#![allow(unused)]
fn main() {
let mut lp = LinearProgram::new(2);
lp.set_binary(1);          // integral, and bounded to [0, 1]
lp.has_integers();         // true
}

set_binary sets the bounds as well as the type deliberately. An "integral but unbounded" indicator is not a binary, and the big-M constraints written against one are wrong in exactly the cases where the bound would have bound.

Integrality is not a hint. A backend that cannot honour it refuses the problem:

BackendIntegrality
opf::ipm (default, in CI)Refuses — OpfError::IntegralityUnsupported
opf::bnb (in CI)Honoured, by branch and bound over ipm
opf::highs (opf-highs)Honoured, via HiGHS's branch-and-cut

A barrier method has no way to enforce an integer, and returning the relaxation would be the worst available answer: a phase shifter cannot sit at tap 4.3, and reporting that it should is a plausible number nobody can act on. Refusing is what lets a caller discover at the boundary that it needs the other backend.

opf::bnb::BranchAndBound is what makes a discrete problem answerable without a system library, and therefore testable in CI:

#![allow(unused)]
fn main() {
let mut solver = BranchAndBound::new();      // over the in-house interior-point method
let solution = solver.solve(&program)?;
solver.nodes();                              // what the search cost
solver.gap();                                // 0.0 means proven optimal
}

Depth-first, diving toward the rounded value, branching on the most fractional column. Best-first proves optimality with fewer nodes but spends a long time with no integer solution at all — and an incumbent is what lets the bound prune anything. The budget is a node count rather than a time limit, because a search that answers differently depending on machine speed cannot be regression-tested.

Two things it is careful about. An exhausted tree proves optimality, so the gap is zero — the root relaxation's bound is a lower bound on the optimum, not evidence about the answer, and reporting the distance to it would call an exact result 5% uncertain. And integral columns come back as exact integers: the relaxation lands within tolerance and no closer, so handing back 3.9999999997 makes every downstream as i32 a truncation the caller did not know it was making.

No cutting planes, no presolve, no heuristics beyond the dive. For a few dozen discrete taps and activation indicators that is enough; a large or hard MILP should still go to HiGHS, and the two are cross-checked against each other on 60 randomised instances in tests/opf_milp_test.rs.

Two further rules, both enforced by validate:

  • No mixed-integer quadratic programs. Integrality plus a Hessian is rejected outright, because neither backend solves one and a silently linearized answer is worse than an error.
  • A MIP reports no duals. HiGHS fills the dual arrays anyway, from the final LP relaxation at the winning node. Those are not shadow prices of the integer problem, so they are cleared rather than passed through as plausible-looking numbers.

What is not here yet

Transformer taps and phase shifters as decision variables. Both are read and held fixed. Taps are genuinely discrete, so a continuous relaxation gives a bound that needs rounding and a re-solve before anyone acts on it.

Unit commitment. On/off decisions make this a mixed-integer program. The solver boundary now can express one — LinearProgram::col_integral, with set_binary for an indicator — and the HiGHS backend honours it through its branch-and-cut solver. What is still missing is the modelling: nothing in opf::dc or opf::ac builds those columns. See Integrality.

Security constraints. N-1 constrained OPF needs contingency cases inside the optimization. The DC outage factors are the screening half of this and already exist; the constrained optimization is not built on them.

Two Buses, One Congested Line

The OPF page says the prices are usually the reason to run it. This one derives where they come from, on the smallest network that has any: two buses, two generators, one line, one number that changes everything.

The system

   bus 1                      bus 2
   ┌───┐      line, x=0.1     ┌───┐
   │ G1│──────────────────────│ G2│
   └───┘   rate = R MW        └───┘
   $10/MWh                    $30/MWh
   0..200 MW                  0..200 MW
                                │
                            100 MW load

Committed as docs/examples/two-bus-lmp.json and its .opf.json companion. Both generators are linear-cost — \(c_2 = 0\) — so this is an LP and the answer is provably optimal, not locally optimal.

The document numbers its nodes 1 and 2, which this page follows; the CLI prints gridoxide's own zero-based bus indices, so bus 0 below is node 1.

The program

With bus 1 as the angle reference the DC-OPF has three variables — \(P_1\), \(P_2\), \(\theta_2\) — and reads

\[ \begin{align*} \min\quad & 10 P_1 + 30 P_2 \\ \text{s.t.}\quad & P_1 - b,(\theta_1 - \theta_2) = 0 && (\lambda_1) \\ & P_2 - b,(\theta_2 - \theta_1) = 100 && (\lambda_2) \\ & -R \le b,(\theta_1 - \theta_2) \le R && (\mu^{-}, \mu^{+}) \\ & 0 \le P_1, P_2 \le 200 \end{align*} \]

The balance rows are written generation minus outflow on the left, load on the right, which is not cosmetic: with load on the right-hand side each row's dual is \(\partial(\text{cost})/\partial(\text{load})\) directly — which is the locational marginal price, positive, in the sign anyone expects. The opposite orientation yields the negated quantity, and the resulting sign error would appear in exactly the output most likely to be published.

Case A: nothing binds

Take \(R = 200\) MW. The line can carry everything, so the cheap generator serves the whole load:

\[ P_1 = 100,\quad P_2 = 0,\quad \text{cost} = $1000/\text{h} \]

The prices follow from stationarity. With \(\mu^{\pm} = 0\) and \(P_1\) strictly between its bounds, the \(P_1\) column gives \(10 - \lambda_1 = 0\), and the \(\theta_2\) column — which appears in both balance rows with equal and opposite coefficients and in no binding limit — gives \(\lambda_1 = \lambda_2\). So

\[ \lambda_1 = \lambda_2 = $10/\text{MWh} \]

One price everywhere. \(P_2\) sits at its lower bound with a positive reduced cost of \(30 - 10 = $20\)/MWh, which is exactly the amount by which it is too expensive to run.

$ gridoxide opf docs/examples/two-bus-lmp.json

2 bus(es), 2 generator(s), 100.0 MW of demand
total cost: 1000.00 $/h

dispatch (MW):
  generator    0:    100.000
  generator    1:      0.000 (at min)

locational marginal price ($/MWh):
  uniform at 10.0000 — nothing is congested

no binding branch limits

A uniform price is the correct answer here, and it is also what an OPF prints when every branch limit is accidentally unlimited. OpfData::binding_capable_branches exists as a named accessor for exactly that reason: an all-unlimited case makes every congestion constraint vacuous and every price identical, which looks like a working OPF right up until someone reads the prices.

Case B: the line binds

Set rate_a to 60 MW. Now the cheap generator cannot serve more than 60, and the remaining 40 must come from the expensive one:

\[ P_1 = 60,\quad P_2 = 40,\quad \text{cost} = 600 + 1200 = $1800/\text{h} \]

Both generators are now strictly inside their boxes, so stationarity fixes both prices directly:

\[ \lambda_1 = 10, \qquad \lambda_2 = 30 \]

Bus 2's price is set by the marginal unit at bus 2, because no more power can reach it from bus 1. The \(\theta_2\) column now also touches the binding limit row, and its stationarity condition gives the limit's own dual:

\[ \mu^{+} = \lambda_2 - \lambda_1 = $20/\text{MWh} \]

Three readings of that same number, all correct and all worth having:

  • The price spread. \(\lambda_2 - \lambda_1 = 20\) — the congestion, in dollars.
  • The shadow price of capacity. One more MW of rating on that corridor would save \$20/h. This is the number that justifies a reinforcement, and it is why an OPF is run for planning and not only for dispatch.
  • The cost of the constraint. Serving that marginal MW at bus 2 costs \$30 instead of \$10 because the line is full.
$ gridoxide opf docs/examples/two-bus-lmp.json

2 bus(es), 2 generator(s), 100.0 MW of demand
total cost: 1800.00 $/h

dispatch (MW):
  generator    0:     60.000
  generator    1:     40.000

locational marginal price ($/MWh):
  bus    0:    10.0000
  bus    1:    30.0000
  spread: 20.0000 (the congestion)

binding branch limits:
  branch    0: flow     60.000 of    60.000 MW, worth   20.0000 $/MWh to relieve

Congestion rent

Settle every generator at its own bus's price and every load at its own:

\[ \underbrace{100 \times 30}{\text{load pays}} ;-; \underbrace{60 \times 10 + 40 \times 30}{\text{generators are paid}} = 3000 - 1800 = $1200/\text{h} \]

The market collects \$1200/h more than it pays out. That surplus is the congestion rent, and it is not an accounting error: it equals the shadow price times the flow,

\[ \mu^{+} \times F = 20 \times 60 = $1200/\text{h} \]

which is the general identity. It accrues to whoever owns the constrained transmission, and it is the economic argument that a line worth building pays for itself.

Note also that the cheap generator is paid \$10 while the system's marginal cost at the other end is \$30. Nothing is being taken from it: it is at its limit, and the limit is the line's, not its own.

Why the duals are trustworthy

For a convex problem the KKT conditions are a proof of optimality, not a comparison against another tool. Every pglib case in the test suite asserts stationarity, primal and dual feasibility, and complementary slackness — the four conditions used informally above — so a dual that is reported is a dual that was checked.

That machinery is what separates "our model differs from theirs" from "our answer is wrong", and in the case30 susceptance investigation it is what made the discrepancy diagnosable at all: the certificate cleared the solver, which left the formula.

Where this stops being exact

The whole page rests on DC being a linear model, which makes the problem convex and the prices unique. Two things break that:

AC. The AC-OPF is nonconvex, so its answer is a local optimum satisfying the first-order conditions — which is the state of the art, and what every published AC-OPF objective means. Its duals are still \(\partial(\text{cost})/\partial(\text{load})\) at that point, and still useful, but "the" price is no longer a well-defined global quantity.

Integrality. A binary decision — a unit commitment, a discrete tap — destroys convexity outright and with it the meaning of a dual. IpmSolver refuses an integer variable rather than relaxing it silently, because a relaxed answer that is reported as an optimum is worse than no answer.

Reproducing this

cargo run --release --features opf -- opf docs/examples/two-bus-lmp.json

The committed .opf.json has rate_a: 60.0, giving Case B. Change it to 200.0 for Case A. The network document itself is unchanged between the two — congestion is a property of the limit, not of the grid.

The Short-Circuit Problem

Motivation

Power flow and state estimation both describe a network that is working. Short-circuit analysis describes one that has just failed: a conductor has touched ground, or two phases have touched each other, and for the next few cycles an enormous current flows through whatever path the impedances allow.

The number that comes out — the initial symmetrical short-circuit current \(I_k''\) — is one of the most consequential in power engineering. A circuit breaker is chosen because it can interrupt it. A conductor is sized because it can survive it for the fraction of a second before protection clears. A protective relay is set because it can distinguish it from load. Get it wrong in one direction and equipment explodes; wrong in the other and faults go undetected.

What makes this not a power flow

Three things, and all three are deliberate simplifications that IEC 60909 makes so the answer does not depend on the operating point:

Loads and generation are ignored entirely. Not linearized, not approximated — dropped. A short-circuit current is a property of the network's impedances, and the standard isolates it from whatever the dispatch happened to be that afternoon. This is the assumption that makes a short-circuit study a property of the grid rather than of a moment. Shunt admittances are kept: a shunt is a passive element, not a dispatch decision.

Sources sit behind a fixed EMF, scaled by a voltage factor \(c\) rather than by their own setpoint — see below.

The problem is linear. With every load gone, what remains is a linear circuit: impedances, fixed voltage sources, and a fault. One sparse factorization, no initial guess, no iteration count, no convergence to fail — the same character as the constant-admittance linearization, and for the same reason. A short-circuit calculation either has a solvable network or it does not.

The voltage factor c

Rather than model the pre-fault voltage profile, IEC 60909 replaces every source EMF with \(c \cdot U_n / \sqrt{3}\), where \(c\) is a single tabulated factor:

\(c_{max}\)\(c_{min}\)
\(U_n \le 1\) kV1.100.95
\(U_n > 1\) kV1.101.00

The two columns are two different studies, not two guesses at one answer:

  • \(c_{max}\) gives the largest credible fault current. This is what breaker ratings and thermal withstand are sized against.
  • \(c_{min}\) gives the smallest. This is the protection-sensitivity study: if the relay can still see a fault at the end of the feeder under the least favourable conditions, it will always see it.

gridoxide exposes the choice as VoltageScaling::{Maximum, Minimum} and nothing else — see Deliberate omissions. The standard distinguishes a 6% and a 10% low-voltage tolerance for \(c_{min}\); like power-grid-model, gridoxide assumes 10%.

One detail worth stating because it is easy to get wrong: the factor replaces the source's u_ref rather than multiplying it. A source declared at 1.02 p.u. contributes \(c\), not \(1.02c\). That is precisely what isolates the result from the operating point, and it is invisible in every reference fixture because they all set u_ref = 1.0.

Formulation: the phase domain

Two formulations are in common use, and they are not approximations of each other — they are the same problem in different coordinates.

The sequence domain (0-1-2) is what IEC 60909 itself is written in, and what most textbooks teach. Build three decoupled networks — zero, positive, negative — reduce each to a Thévenin equivalent at the fault point, and connect them in the pattern the fault type dictates: positive alone for a three-phase fault, all three in series for single-phase-to-ground, and so on.

The phase domain (abc) keeps the full \(3N \times 3N\) nodal system and writes the fault's boundary conditions directly into it, rewriting the rows and columns at the faulted bus:

\[ I_N = Y_{bus} , U_N \]

gridoxide solves in the phase domain. Two reasons. First, unbalanced faults fall out without special-casing each one — a fault type is a different set of rows to rewrite, not a different network to assemble. Second, and decisively for this project, it is what power-grid-model does, so its fifteen short-circuit fixtures cross-validate gridoxide's numerics directly. That is the same standard every other numeric feature here is held to.

Results are then reported in both bases: phase quantities, and symmetrical components via the inverse Fortescue transform. The sequence view is where a fault type's signature is legible at a glance, which is most of why it is worth having — see Fault Types.

Both formulations are written out in Symmetrical Components and the Fault Equations, and A Fault Current, by Hand computes three of them on a one-node system where every step is a calculator away.

The cost of that choice

The phase domain needs the network to have a path to ground. An ungrounded network has a singular zero-sequence system in the phase domain — it does not in the sequence domain, where the zero-sequence network is simply absent — and comes back as ShortCircuitError::Singular.

This is inherited from the formulation, and power-grid-model documents the same limitation for itself. In practice a real network is grounded somewhere; the case that bites is a small test model with a delta winding on both sides of a node. gridoxide follows power-grid-model in adding a tiny artificial susceptance to ground a winding that has no zero-sequence path of its own, purely so the matrix stays non-singular. It is a numerical device, not physics, and its exact value is arbitrary — a point that matters when comparing against reference data, as tests/data/pgm/short_circuit/README.md records in detail.

Islands

A bus with no path to any source cannot carry a fault current, so a fault declared there returns zero and the bus is reported de-energized and pinned at zero volts. The energized part of the network is solved on its own.

This is the same contract every other solver in gridoxide offers — see Multi-Island Power Flow — and it exists for the same reason: a sourceless component has no reference, and inventing one would be guessing.

Deliberate omissions

gridoxide implements the voltage factor \(c\) and nothing else of IEC 60909's wider parameterization. In particular it does not model:

  • Study type (sub-transient / transient / steady-state) and the associated machine reactances. powsybl's short-circuit API models these; gridoxide does not read the machine data they need.
  • Configurable initial voltage profiles — user-supplied voltage ranges, or seeding from a previously solved power flow.
  • Peak, breaking and thermal-equivalent currents (\(i_p\), \(I_b\), \(I_{th}\)), which are derived from \(I_k''\) by further standard factors.

Each is a real capability elsewhere. None is implemented here because no fixture in this tree can validate it, and this project's rule is that a number it prints is a number something checked.

Using it

From the CLI:

gridoxide short-circuit network.json --scaling max

From Rust:

#![allow(unused)]
fn main() {
use gridoxide::pgm::shortcircuit::short_circuit_from_pgm;
use gridoxide::shortcircuit::{ShortCircuitOptions, VoltageScaling};

let opts = ShortCircuitOptions { scaling: VoltageScaling::Maximum };
let (net, report) = short_circuit_from_pgm(&input, 1e6, 50.0, opts)?;
for fault in &report.faults {
    println!("fault {}: {:.1} A on phase a", fault.id, fault.i_f[0]);
}
for branch in &report.branches {
    println!("branch {}: {:.1} A into its `to` end", branch.id, branch.i_to[0]);
}
}

From Python:

import gridoxide

result = gridoxide.short_circuit("network.json", scaling="max")
for fault in result.faults:
    print(fault.id, fault.i_f)
for branch in result.branches:
    print(branch.id, branch.i_from, branch.i_to)

The report covers five component kinds: nodes (voltages, and their symmetrical components), faults, sources, branches — lines, transformers and links alike, with per-phase currents at both terminals, each on its own node's base — and shunts. Branch currents are what a protection study grades against; a fault current alone says what the fault draws, not what any particular breaker sees. Fully open branches and inactive shunts have no entry, and a branch that no closed terminal connects to an energized node is reported de-energized, carrying zero.

The fault itself is declared in the input document, as power-grid-model's fault component: a node, a fault type, an optional phase, and an optional fault impedance. A fault with no r_f/x_f is a bolted fault — a dead short — not one that draws no current.

Fault Types and Their Boundary Conditions

Four fault situations are distinguished, and they differ from each other only in what is constrained at the faulted bus. Everything else — the network, the sources, the voltage factor — is identical. This page is about those constraints and how to read the result.

The four types

TypePhasesRough frequency in practiceZero sequence?
Three-phaseallrareno (balanced)
Single-phase-to-groundonethe large majorityyes
Two-phasetwo, clear of grounduncommonno
Two-phase-to-groundtwo, plus grounduncommonyes

The three-phase fault is the balanced one and usually — though not always — gives the largest current, which is why it is the default sizing case. The single-phase-to-ground fault is by a wide margin the most common in real systems, and it is the one whose magnitude depends most on transformer winding configuration, because its current has to return through the zero-sequence network.

Default phases

A fault may name its phases explicitly (a, b, c, ab, ac, bc) or leave it to the type:

TypeDefault
Three-phaseabc
Single-phase-to-grounda
Two-phasebc
Two-phase-to-groundbc

The two two-phase types default to bc, not ab. This follows power-grid-model, and it is worth stating loudly because getting it wrong produces an entirely plausible magnitude on the wrong pair of conductors — the kind of error that survives a casual review.

Bolted versus impedance faults

A bolted fault is a dead short: zero impedance, therefore infinite admittance. An impedance fault goes through some finite \(Z_f\) — an arc, a tree, a resistive earth path.

The two are not the same formula evaluated at a limit, and gridoxide does not treat them that way. An impedance fault adds an admittance to the faulted bus's diagonal. A bolted fault replaces that bus's equations: its voltage is known to be zero, so the unknown in that position becomes the injected current instead. Letting an infinity into the matrix would produce NaN, not a large current.

The practical consequence for input data: r_f = x_f = 0 means bolted, and that is the default when a fault omits them. A fault that draws no current is not something the component can express, because it would not be a fault.

Reading the symmetrical components

Every result carries the symmetrical-component view alongside the phase quantities, and it is the fastest way to tell whether an answer is sane:

  • Three-phase — positive sequence only. Zero and negative should be numerical noise. If they are not, either the network is genuinely unbalanced or something is wrong.
  • Single-phase-to-ground — all three components present, comparable in size.
  • Two-phase (clear of ground) — no zero-sequence component at all. With no ground return there is nowhere for zero-sequence current to go. This is the single sharpest check available on a short-circuit result.
  • Two-phase-to-ground — all three present.

A second useful check: a delta winding blocks zero-sequence current. If a node sits behind one and still shows zero-sequence voltage, the transformer's zero-sequence branch is wrong.

Both properties are asserted directly in python/tests/test_short_circuit.py.

Multiple simultaneous faults

Several faults may be declared at once, on the same bus or on different ones, subject to one restriction: they must all share a fault type and phase. The boundary conditions of different types rewrite the same matrix entries in incompatible ways, so "one of each, at once" is not a well-posed linear system. Mixing them returns ShortCircuitError::MixedFaultTypes rather than a plausible-looking answer.

Two faults on one bus divide the current between them. A bolted fault short-circuits its bus outright, so any other fault sharing that bus — bolted or not — is bypassed and carries nothing.

Symmetrical Components and the Fault Equations

The problem page says gridoxide solves in the phase domain and reports in both. This page is the mathematics of that: the transform, the sequence networks IEC 60909 is written in, the boundary conditions each fault type imposes, and how those conditions are stamped into a phase-domain matrix.

The transform

Any three phase quantities decompose into three balanced sets. With \(a = e^{j2\pi/3} = 1\angle 120°\),

\[ \begin{pmatrix} X_a \\ X_b \\ X_c \end

\underbrace{\begin{pmatrix} 1 & 1 & 1 \\ 1 & a^2 & a \\ 1 & a & a^2 \end{pmatrix}}_{A} \begin{pmatrix} X_0 \\ X_1 \\ X_2 \end{pmatrix}, \qquad \begin{pmatrix} X_0 \\ X_1 \\ X_2 \end{pmatrix} = \frac{1}{3} \begin{pmatrix} 1 & 1 & 1 \\ 1 & a & a^2 \\ 1 & a^2 & a \end{pmatrix} \begin{pmatrix} X_a \\ X_b \\ X_c \end{pmatrix} \]

Three useful identities fall straight out of \(1 + a + a^2 = 0\):

\[ X_0 = \tfrac{1}{3}\bigl(X_a + X_b + X_c\bigr), \qquad X_a = X_0 + X_1 + X_2, \qquad \sum_{\text{phases}} X = 3X_0 \]

The first is the whole reason zero sequence matters: it is the average of the three phases, so it is non-zero exactly when the three do not sum to zero — which for currents means current is returning through the ground. No ground path, no zero sequence. That single fact explains most of the qualitative behaviour on this page.

The three sequences are named for what they do to a machine: positive rotates the normal way, negative rotates backwards, zero does not rotate at all.

shortcircuit::fortescue is the \(A^{-1}\) above, and it is the exact inverse of the \(A\) that network::fortescue_to_phase builds the phase-domain Y-bus with, so a result projected into sequences and pushed back returns where it started.

Why the sequences decouple

For a network whose three phases are symmetric, \(A^{-1} Y_{abc} A\) is block-diagonal:

\[ Y_{012} = \begin{pmatrix} Y_0 & & \\ & Y_1 & \\ & & Y_2 \end{pmatrix} \]

Three independent networks, each a third the size, none talking to the others. That is what makes the sequence domain the natural place to write a standard in, and it is why IEC 60909 states its formulae in \(Z_0, Z_1, Z_2\).

For a passive network \(Z_2 = Z_1\): a line does not care which way the phasors rotate. \(Z_0\) is different — usually two to four times \(Z_1\) for an overhead line, because the return path is the earth rather than a conductor. gridoxide takes it from the input directly: a source's z01_ratio is the multiplier \(Z_0 / Z_1\), and lines carry r0/x0 of their own.

The couplings are what the fault introduces. A fault is the one place the three phases are not treated alike, so it appears in the sequence domain as an interconnection between the three otherwise independent networks — and the pattern of that interconnection is the fault type.

The four fault types as boundary conditions

Reduce each sequence network to its Thévenin equivalent at the faulted bus — \(Z_1, Z_2, Z_0\) looking in, with the pre-fault voltage \(E = c,U_n/\sqrt{3}\) driving the positive-sequence one — and every fault type becomes a way of wiring the three together.

FaultPhase-domain conditionsSequence connection\(I_a\) (or \(I_b\))
Three-phase\(U_a = U_b = U_c = Z_f I\)positive only\(\dfrac{E}{Z_1 + Z_f}\)
Line-to-ground (a)\(I_b = I_c = 0\), \(U_a = Z_f I_a\)all three in series\(\dfrac{3E}{Z_1 + Z_2 + Z_0 + 3Z_f}\)
Two-phase (b–c)\(I_a = 0\), \(I_b = -I_c\), \(U_b - U_c = Z_f I_b\)1 and 2 in parallel, 0 absent\(\dfrac{-j\sqrt{3},E}{Z_1 + Z_2 + Z_f}\)
Two-phase-to-ground\(I_a = 0\), \(U_b = U_c = Z_f(I_b + I_c)\)1 in series with (2 ∥ (0 + 3\(Z_f\)))—

Three things in that table are worth stating out loud.

The \(3\) in the line-to-ground numerator and the \(3Z_f\) are the same \(3\). All of \(I_a\) flows through the fault impedance, but \(I_a = 3I_0 = 3I_1\) because the three sequence currents are equal, so referred to one sequence network the impedance appears as \(3Z_f\).

Two-phase has no zero-sequence term at all, because there is no ground return. This is the sharpest available check on a short-circuit result: if a two-phase fault reports a zero-sequence component, something is wrong.

Where \(Z_0 = Z_1 = Z_2\), a line-to-ground fault draws exactly the same current as a three-phase one — \(3E/3Z = E/Z\). That degeneracy is easy to hit in a test fixture (PGM's z01_ratio defaults to 1) and easy to mistake for a bug. Where \(Z_0 > Z_1\), which is the normal case, the line-to-ground current is smaller; near a solidly grounded transformer, where \(Z_0\) can be less than \(Z_1\), it is larger, and that is why the line-to-ground case sometimes sizes the breaker.

The phase domain, and why gridoxide uses it

The sequence formulae above require reducing the network to a Thévenin equivalent at the fault, per sequence, per fault location. The phase domain instead keeps the whole \(3N \times 3N\) nodal system

\[ I_N = Y_{bus}, U_N, \qquad U_N = \bigl[,U_{1a}, U_{1b}, U_{1c},\ U_{2a}, \ldots,\bigr]^{T} \]

and writes the fault's boundary conditions into it directly, by rewriting rows and columns at the faulted bus. Two reasons this is the better choice here:

  • Unbalanced faults fall out without special-casing. A fault type is a different set of rows to rewrite, not a different network to assemble.
  • It is what power-grid-model does, so its fifteen short-circuit fixtures cross-validate gridoxide's numerics directly.

\(Y_{bus}\) is built by transforming each element's sequence admittances into phase coordinates with \(A\), which for a symmetric element gives the familiar circulant

\[ Y_{abc} = A \begin{pmatrix} y_0 & & \\ & y_1 & \\ & & y_2 \end{pmatrix} A^{-1} = \begin{pmatrix} y_s & y_m & y_m \\ y_m & y_s & y_m \\ y_m & y_m & y_s \end{pmatrix}, \quad y_s = \tfrac{y_0 + 2y_1}{3},\ \ y_m = \tfrac{y_0 - y_1}{3} \]

with \(y_2 = y_1\). The mutual term \(y_m\) vanishes exactly when \(y_0 = y_1\), which is the statement that a network with identical sequence impedances has no phase coupling at all.

Stamping the fault

An impedance fault and a bolted fault are not the same formula evaluated at a limit, and the code does not treat them that way.

An impedance fault adds admittance. For a line-to-ground fault on phase \(p\) at bus \(k\), one entry:

\[ Y[3k{+}p,\ 3k{+}p] \mathrel{+}= y_f \]

A two-phase fault between \(p_1\) and \(p_2\) ties them to each other rather than to ground, so it stamps the usual four-entry branch:

\[ Y[p_1,p_1] \mathrel{+}= y_f,\quad Y[p_2,p_2] \mathrel{+}= y_f, \quad Y[p_1,p_2] \mathrel{-}= y_f,\quad Y[p_2,p_1] \mathrel{-}= y_f \]

A bolted fault has \(y_f = \infty\), and letting an infinity into the matrix produces NaN, not a large current. So the faulted bus's equations are replaced: its voltage is known to be zero, so the unknown in that position becomes the injected current instead. In the matrix that is a column erased and a \(-1\) written on the diagonal, with the corresponding right-hand side set to zero.

The two-phase case needs different surgery again, because its constraint is \(U_{p_1} = U_{p_2}\) rather than either being zero: the two columns are folded together rather than erased, and one row becomes the difference equation. Two-phase-to-ground needs both operations at once — the phases are tied to each other and the pair is tied to ground — which is why it takes the column surgery even with a finite impedance.

The practical consequence for input data: r_f = x_f = 0 means bolted, and that is the default when a fault omits them. A fault that draws no current is not something the component can express, because it would not be a fault.

The cost of the phase domain

The phase domain needs the network to have a path to ground. An ungrounded network has a singular zero-sequence system in the phase domain — it does not in the sequence domain, where the zero-sequence network is simply absent — and comes back as ShortCircuitError::Singular.

In practice a real network is grounded somewhere; the case that bites is a small test model with a delta winding on both sides of a node. gridoxide follows power-grid-model in adding a tiny artificial susceptance to ground such a winding, purely so the matrix stays non-singular. It is a numerical device, not physics, and its exact value is arbitrary — a point that matters when comparing against reference data, as tests/data/pgm/short_circuit/README.md records.

Reading a result

Every result carries the sequence view alongside the phase quantities, and it is the fastest way to tell whether an answer is sane:

Fault\(X_0\)\(X_1\)\(X_2\)
Three-phase≈ 0large≈ 0
Line-to-groundpresentpresentpresent
Two-phaseexactly 0presentpresent
Two-phase-to-groundpresentpresentpresent

A second useful check: a delta winding blocks zero-sequence current. If a node sits behind one and still shows zero-sequence voltage, the transformer's zero-sequence branch is wrong. Both properties are asserted directly in python/tests/test_short_circuit.py.

The worked example carries all of this through on numbers small enough to check by hand.

A Fault Current, by Hand

One node, one source, one fault. Everything on this page can be checked with a calculator, and every figure quoted from gridoxide was produced by running the command shown.

The system

        ┌──── Z_s ────┐
   E ───┤             ├──── node 1 (U_n = 10 kV)
        └─────────────┘            │
                                 ┌─┴─┐
                                 │Z_f│   r_f = x_f = 0.1 Ω
                                 └─┬─┘
                                  ─┴─

A single 10 kV node fed by a source of short-circuit power \(S_k'' = 100\) MVA with an R/X ratio of 0.1, faulted through 0.1 + j0.1 Ω. That is power-grid-model's single_node_source_three_phase_c_maximum fixture verbatim:

"node":   [{"id": 1, "u_rated": 10000.0}],
"source": [{"id": 2, "node": 1, "status": 1, "u_ref": 1.0, "sk": 1e8, "rx_ratio": 0.1}],
"fault":  [{"id": 3, "status": 1, "fault_type": 0, "fault_phase": -1,
            "fault_object": 1, "r_f": 0.1, "x_f": 0.1}]

Step 1: the source impedance

A short-circuit power is a statement about impedance:

\[ Z_s = \frac{U_n^2}{S_k''} = \frac{(10^4)^2}{10^8} = 1.000\ \Omega \]

Then the R/X ratio splits it. With \(R = 0.1X\) and \(\vert Z \vert = X\sqrt{1 + 0.1^2}\),

\[ X_s = \frac{1.000}{\sqrt{1.01}} = 0.995037\ \Omega, \qquad R_s = 0.1,X_s = 0.099504\ \Omega \]

The voltage factor \(c\) does not appear here. \(c\) scales the source's EMF, not its impedance, and it replaces u_ref rather than multiplying it — a source declared at 1.02 p.u. contributes \(c\), not \(1.02c\). That is precisely what isolates the result from the operating point, and it is invisible in every reference fixture because they all set u_ref = 1.0.

Step 2: the driving voltage

IEC 60909 replaces every source EMF with a line-to-neutral \(cU_n/\sqrt{3}\). Taking \(c = c_{max} = 1.10\) for a network above 1 kV:

\[ E = \frac{1.10 \times 10^4}{\sqrt{3}} = 6350.853\ \text{V} \]

Step 3: the three-phase current

A three-phase fault is balanced, so only the positive-sequence network carries anything, and it is a single loop:

\[ Z_1 + Z_f = (0.099504 + 0.1) + j(0.995037 + 0.1) = 0.199504 + j1.095037\ \Omega \]

\[ \vert Z_1 + Z_f \vert = \sqrt{0.039802 + 1.199106} = \sqrt{1.238908} = 1.113063\ \Omega \]

\[ I_k'' = \frac{E}{\vert Z_1 + Z_f \vert} = \frac{6350.853}{1.113063} = \mathbf{5705.75\ A} \]

And the faulted node does not sit at zero volts, because the fault is not bolted — it sits at the drop across \(Z_f\):

\[ U_1 = I_k'',\vert Z_f \vert = 5705.75 \times 0.141421 = 806.90\ \text{V} \]

which as a per-unit of the line-to-neutral base \(10^4/\sqrt{3} = 5773.50\) V is

\[ u_1 = \frac{806.90}{5773.50} = 0.13976\ \text{p.u.} \]

What the code says

$ gridoxide short-circuit \
      tests/data/pgm/short_circuit/single_node_source_three_phase_c_maximum/input.json \
      --scaling max

1 node(s), 1 fault(s), 1 source(s), voltage scaling = c_max (largest current)

fault currents (A):
  fault      3: a =      5705.75, b =      5705.75, c =      5705.75

node voltages (p.u.):
  node      1 : a =  0.13976, b =  0.13976, c =  0.13976

symmetrical components of node voltage (p.u.):
  node      1: zero =  0.00000, positive =  0.13976, negative =  0.00000

Both hand figures to every digit printed, and the sequence line is the expected signature: positive only, zero and negative at machine noise. That fixture's published expectation is 5705.7468249952872 A, so the agreement is not just with gridoxide but with power-grid-model.

The c_min study is a different question

Rerun with --scaling min and \(c\) becomes 1.00, so \(E = 5773.50\) V and \(I = 5187.04\) A — 9.1% lower, exactly the ratio \(1.00/1.10\), because \(c\) enters linearly and the impedance does not move. That number is not a less accurate version of the first; it is the answer to the protection-sensitivity question, where the first answers the breaker-rating question.

Step 4: an unbalanced fault

Change two things — make the fault line-to-ground on phase a, and give the source a realistic \(Z_0 = 3Z_1\):

"source": [{"id": 2, "node": 1, "status": 1, "u_ref": 1.0, "sk": 1e8,
            "rx_ratio": 0.1, "z01_ratio": 3.0}],
"fault":  [{"id": 3, "status": 1, "fault_type": 1, "fault_phase": 1,
            "fault_object": 1, "r_f": 0.1, "x_f": 0.1}]

(committed as docs/examples/one-node-slg.json). Now all three sequence networks are in series:

\[ Z_1 + Z_2 + Z_0 = (1 + 1 + 3),Z_1 = 5Z_1 = 0.497519 + j4.975186\ \Omega \]

\[ Z_1 + Z_2 + Z_0 + 3Z_f = 0.797519 + j5.275186, \qquad \vert \cdot \vert = 5.335131\ \Omega \]

\[ I_a = \frac{3E}{\vert Z_1 + Z_2 + Z_0 + 3Z_f \vert} = \frac{3 \times 6350.853}{5.335131} = \mathbf{3571.15\ A} \]

Smaller than the three-phase current, which is the usual ordering once \(Z_0 > Z_1\). Note that setting z01_ratio back to 1 would have made the two identical — \(3E/3Z_1 = E/Z_1\) — a degeneracy easy to hit in a fixture and easy to mistake for a bug.

The sequence voltages, also by hand

With the three networks in series the sequence currents are equal: \(I_0 = I_1 = I_2 = I_a/3 = 1190.38\) A. The zero and negative sequence networks have no source, so their voltages are pure drops:

\[ \vert U_0 \vert = I_0 \vert Z_0 \vert = 1190.38 \times 3.000 = 3571.15\ \text{V} = 0.61854\ \text{p.u.} \] \[ \vert U_2 \vert = I_2 \vert Z_2 \vert = 1190.38 \times 1.000 = 1190.38\ \text{V} = 0.20618\ \text{p.u.} \]

The positive-sequence one needs the phase kept, because it is a difference rather than a product: \(U_1 = E - I_1 Z_1\), and \(I_1 Z_1\) leads \(E\)'s reference by \(\arg Z_1 - \arg Z_{total} = 84.29° - 81.40° = 2.89°\). So

\[ U_1 = 1.10 - 0.20618\angle 2.89° = 0.89408 - j0.01039, \qquad \vert U_1 \vert = 0.89414\ \text{p.u.} \]

Taking the magnitudes naively — \(1.10 - 0.20618 = 0.89382\) — is wrong in the fourth decimal, which is a good demonstration that the small angle is real and not rounding.

$ gridoxide short-circuit docs/examples/one-node-slg.json --scaling max

fault currents (A):
  fault      3: a =      3571.15, b =         0.00, c =         0.00

node voltages (p.u.):
  node      1 : a =  0.08747, b =  1.36844, c =  1.33922

symmetrical components of node voltage (p.u.):
  node      1: zero =  0.61854, positive =  0.89414, negative =  0.20618

All three sequence magnitudes to five decimals. Two more things are visible in that output and worth naming:

  • \(u_a = 0.08747\) is again \(I_a \vert Z_f \vert\) in per-unit: \(3571.15 \times 0.141421 / 5773.50\). The faulted phase collapses; the healthy phases rise, to 1.37 and 1.34 p.u. That neutral-shift overvoltage on the unfaulted phases is a real effect and is why insulation is rated against it.
  • \(I_b = I_c = 0\) exactly, which is the boundary condition the fault type imposes, recovered rather than assumed.

Step 5: a fault with no ground return

Change nothing but the fault type — two-phase between a and b, clear of ground (docs/examples/one-node-2ph.json, which is the file above with fault_type: 2, fault_phase: 4):

\[ I = \frac{\sqrt{3},E}{\vert Z_1 + Z_2 + Z_f \vert} = \frac{\sqrt{3} \times 6350.853}{\vert 0.299007 + j2.090074 \vert} = \frac{11000.0}{2.111354} = \mathbf{5209.93\ A} \]

and gridoxide agrees to the last digit printed. The number to look at, though, is the one that is not there:

symmetrical components of node voltage (p.u.):
  node      1: zero =  0.00000, positive =  0.57990, negative =  0.52099

Zero sequence is exactly zero, because \(Z_0\) does not appear in the formula at all. There is no ground return, so there is nowhere for zero-sequence current to go, and the source's z01_ratio: 3.0 is simply irrelevant to this fault. This is the sharpest single check available on a short-circuit result — see reading a result.

What the arithmetic here does not cover

Every number above is an initial symmetrical short-circuit current \(I_k''\). The quantities a switchgear datasheet actually lists — the peak current \(i_p\), the breaking current \(I_b\), the thermal equivalent \(I_{th}\) — are derived from \(I_k''\) by further standard factors that gridoxide does not implement, because no fixture in this tree can validate them. See deliberate omissions.

The RMS Simulation Problem

Motivation

Everything else gridoxide computes describes the network at one instant. A power flow says where the power goes; a short circuit says how much current a fault draws; continuation traces a curve of instants indexed by loading. None of them can answer the question a planner actually asks about a fault:

The breaker will clear in 120 milliseconds. Do the machines stay in step?

That is a question about a trajectory, and answering it means integrating.

Why the machines can fall out of step

A synchronous generator holds position against the grid the way a mass on a spring holds position against gravity. Electrical power out balances mechanical power in, and the rotor sits at whatever angle makes those equal.

A fault removes the electrical power almost entirely — a short circuit near the terminals means the machine is pushing current into something that cannot accept work. The mechanical power does not change; a steam turbine does not notice a fault for several seconds. So the rotor accelerates.

When the fault clears, the electrical power returns and the rotor decelerates. Whether it decelerates enough depends on how far it got: past a certain angle the restoring torque falls rather than rises, and the machine runs away. That threshold — the last instant at which clearing still saves it — is the critical clearing time, and it is the number transient stability exists to produce.

The differential-algebraic form

ẋ = f(x, V)                machines, exciters, governors — the state
Y V − I_inj(x, V) = 0      the network — a constraint, not a state

The second line carries no derivative. That is what makes this a differential-algebraic system rather than an ordinary differential one, and it is not a modelling shortcut: it is the statement that electromagnetic transients in the network settle far faster than the electromechanical ones being simulated, so they are taken as instantaneous.

That time-scale separation is exactly what "RMS" means. A voltage is represented by a phasor whose magnitude and angle vary slowly, rather than by the sinusoid itself. It is what an EMT simulation refuses to assume, and it is why an RMS run can use a five-millisecond step where an EMT run needs microseconds.

What that costs, stated plainly

The phasor representation is an approximation, and gridoxide makes one more on top of it: the stator equations here omit the rotor speed, and the swing equation is written in power rather than torque. The two forms differ by exactly a factor of \(\omega\), so they agree at synchronous speed and part in proportion to the speed deviation.

That is the classical assumption — Kundur §13.3 states it explicitly — and its size is measured rather than assumed: against Dynawo on Kundur's own Example 13.2 it costs 0.6% of terminal power at a 0.9% speed deviation.

Both forms are available. "speed_voltages": true switches to the full one, and doing so drives that 0.6% to 0.026% and the rotor-angle agreement over the first swing from 1.9e-2 to 1.8e-3 rad. The approximation stays the default because it is what makes the phasor formulation coherent, and because every closed-form gate here is derived from the power form. See Against Dynawo.

The shape of a run

  1. Solve a power flow. Every device's initial state is derived from the operating point, so there has to be one.
  2. Initialize backwards. Each machine's states are chosen so its derivatives are zero, and each control's reference is chosen so that its output is exactly what the machine turned out to need. Nothing is read from a file that can be derived from the solve.
  3. Integrate, stopping exactly on each scheduled event, applying it, re-solving the network constraint with the states held fixed, and resuming.

Step 2 is the one that goes wrong, and it has a decisive gate: a run with no disturbance must be a flat line to machine precision. See Initialization.

Beyond a single run

Small-signal analysis linearizes the same system about its equilibrium and reports its modes — which oscillations exist, how fast each decays, and which machines take part. It is the only reliable way to find a negatively damped mode, since a run finds one only if the disturbance happened to excite it. On a case too large to decompose whole it answers a narrower question instead — the modes near a named frequency — and says that is what it answered. It will also say which machine parameter moves a mode, and by how much.

Scope

Machinesclassical (2nd order), transient (4th), salient-pole (5th), subtransient (6th)
ExcitationSEXS, and a purely proportional regulator
GovernorsTGOV1, and a purely proportional governor
Stabilizerswashout plus two lead-lag stages
Loadsconstant admittance by default; ZIP with a low-voltage cutoff on request
Limitsnon-windup on the exciter's field voltage and the governor's valve position, a rate limit on the valve's travel, a clamp on the stabilizer's output
Eventsbus faults, clearings, branch trip and close, unit trip and close, load steps
Protectionrelays on bus voltage, machine speed or angle excursion, with a delay and a located crossing
Inputgridoxide JSON, PSS/E .dyr, Dynawo .dyd/.par

Deliberately not implemented, each for a stated reason:

  • Saturation. PSS/E states it as two points on a curve, Dynawo as an exponential characteristic, and the two are not convertible without committing to a shape. Both readers parse it, neither uses it, and both report a nonzero value rather than dropping it silently.
  • Valve rate limits on a governor. Position limits are implemented; a rate limit constrains a derivative rather than a state, which is a different piece of machinery.

The DAE and Its Jacobian

The formulation, and the one decision everything follows from

gridoxide solves the differential and algebraic halves simultaneously: one Newton per step over the whole vector, with no inner iteration between a device solver and a network solver. There is no interface error to converge away and no per-step lag between the two halves.

The trapezoidal rule on the differential half, the constraint enforced at the new point:

\[ x_{k+1} - x_k - \tfrac{h}{2}\big(f(x_k,V_k) + f(x_{k+1},V_{k+1})\big) = 0 \] \[ \mathbf{Y}V_{k+1} - I_{inj}(x_{k+1},V_{k+1}) = 0 \]

giving the Newton matrix

      ⎡ I − (h/2)·∂f/∂x       −(h/2)·∂f/∂V ⎤
  J = ⎢                                     ⎥
      ⎣    −∂I_inj/∂x        Y − ∂I_inj/∂V  ⎦

Why rectangular coordinates, and why current balance

Write the network in rectangular coordinates and its block is the real form of the admittance matrix:

Y_real = ⎡ G  −B ⎤        I = YV  ⇒  i_re = G v_re − B v_im
         ⎣ B   G ⎦                   i_im = B v_re + G v_im

which is constant for the lifetime of a topology. Nothing about it depends on x or on V. Every moving part is then a device stamp, and every device stamp is local: ∂f/∂x is one dense block per device, ∂f/∂V and ∂I/∂x couple that block to its own bus's two columns and rows, and ∂I/∂V is 2×2 on one diagonal.

A polar power-mismatch Jacobian — what the power flow builds — has none of that structure: every entry moves every iteration. So dynamics borrows that module's shape (analyze once, refill values at fixed offsets, hand the values to the backend) and none of its arithmetic.

One symbolic factorization for a whole run

The sparsity pattern is fixed for the lifetime of a topology, and every event preserves it. That is deliberate. The pattern is built from a topology superset — with every branch in service and every bus diagonal stamped, whether it needs one yet or not — and an out-of-service branch has its positions re-stamped at zero rather than dropped.

So a fault, a clearing, a branch trip and a unit trip are all numeric refills against one symbolic factorization. Nothing re-analyzes for the whole run.

Tripping a generating unit looks like the exception, since its states would leave the system. Freezing it rather than removing it keeps the layout identical — its rows become x₁ − x₀ = 0 and its Jacobian block the identity the implicit rule contributes — and freezing is also the better model: nothing in the network can observe a disconnected machine's rotor.

The Norton stamp, which changes no answer

Each machine declares a constant admittance that is stamped into Y at build, and its injection returns

I_inj = I_machine(x, V) + y_norton·V

so the y_norton·V the network row adds is exactly cancelled. The stamp is purely a conditioning device: it keeps Y diagonally dominant at generator buses without moving a single result.

For a machine with no subtransient saliency the cancellation is exact and ∂I/∂V comes out zero to the last bit — which is the sharpest available check on the whole rotor-frame algebra, and is asserted as an identity rather than a tolerance. For a salient machine a residual remains: the machine is genuinely not an impedance in the network frame, because its response depends on the rotor's orientation. That residual is the saliency.

Two integration rules, one assembly

The trapezoidal rule is second-order and A-stable, which is what a stiff phasor DAE wants. It is not L-stable: its amplification factor tends to −1 rather than 0, so a discontinuity excites a numerical oscillation that alternates sign and decays only as slowly as the physical mode does.

The remedy is a couple of backward Euler steps immediately after each event, then back to trapezoidal. Both rules are the same residual with different coefficients —

x₁ − x₀ − h(a·f₁ + b·f₀) = 0     (a, b) = (½, ½) trapezoidal
                                         (1, 0)  backward Euler

— so nothing branches on which is active beyond choosing the pair. That matters because the rule switches within a run, and two separate assemblies would be two places to get the damping wrong.

The damping is not free, and its cost is known in closed form: backward Euler evaluates δ̇ at the end of the step, so on a linearly growing speed each damping step overshoots by exactly Ω_b·a·h²/2. Two steps leave a permanent offset of Ω_b·a·h², which is 2.5e-5 rad at a 1 ms step on a typical machine.

The backends

All five of gridoxide's sparse LU backends serve this, unchanged, because the DAE Jacobian satisfies the same contract the power flow's does: pattern fixed, values change every call. Block is the exception and is refused — it assumes a uniform 2×2 block per bus, and a DAE has variable-size device blocks alongside the network's.

One caveat worth knowing if you extend this: the backends are interchangeable at solve time but not at construction time. KluNative and Klu factor numerically inside new, so they must be handed real values from an actual fill, not a placeholder pattern.

Initialization from a Power Flow

The rule

Every device is initialized backwards: from its terminal voltage and power, choose the states that make its derivatives zero, and latch whatever internal reference that implies.

A classical machine latches its internal EMF magnitude and its mechanical power. A transient machine additionally derives the field voltage its flux state requires. An exciter latches V_ref so that its output is exactly that field voltage. A governor latches P_ref likewise.

Nothing reads a setpoint from the input file. A file's stated V_ref and its stated power flow are two independent claims, and they will not agree to machine precision. Deriving the setpoint from the operating point makes them agree by construction.

The invariant, and the gate that checks it

After the build, at t = 0:

  • every device's derivative vector is zero, and
  • the network residual Y V − I_inj(x, V) is zero,

both to machine precision, not merely to a solver tolerance. That is not an aspiration; it falls out algebraically. Each machine's Norton current is E·y with E = V + z·I for the very I = conj(S/V) the power flow produced, so the network residual at its bus reduces to the power-flow equation that was already satisfied. Each remaining injection becomes an admittance y = −conj(R)/|V|², which reproduces R exactly at the voltage it was derived at.

So the builder checks both and refuses a system that fails either. Which gives the single most useful check in the whole module:

Run with no disturbance at all. The trajectory must be a flat line.

If initialization is consistent, every derivative is zero at t = 0 and stays zero. If a sign is flipped, a per-unit base missed, a Norton stamp inconsistent with the impedance its own model initialized against, or a residual assembled wrongly, the state drifts immediately and visibly. Nearly every mistake in this module is caught by that one check, and it is run for every model and every combination of controls.

What the check cannot see

A wrong device/load split. If a caller declares that a machine makes half of what it really makes, the result is self-consistent: the machine initializes to an equilibrium at the power it was told, and the remainder is absorbed into the bus's admittance, so both residuals are still exactly zero.

What comes out is a different machine — smaller output, smaller internal EMF, smaller rotor angle — swinging against a network that makes up the difference from something inert. The trajectory is plausible and the answer is wrong.

This is why the equilibrium gate is a code-correctness check and not an input-validation one, and why the file format removes the need to state the split at all: a device may give its own p and q, and if it does not, it takes what its bus's solved injection has left. One device omitting it at a bus is unambiguous; two is refused by name rather than split by a guess.

Deriving the rotor angle

For any machine with a q axis, the rotor angle comes from the steady-state reference

\[ E_q = V + (r_a + j x_q),I, \qquad \delta = \arg E_q \]

That is not a convenience. Choosing δ this way forces the d-axis component of E_q to zero, which reduces the stator relation for e'_d to exactly (x_q − x'_q)·i_q — the expression ė'_d = 0 demands. The same construction works for the subtransient machine, because at steady state a machine looks like x_q behind its terminal on the q axis whatever damper windings it carries.

Two identities check the result exactly, and both are asserted to 1e-12:

  1. The machine reproduces its own terminal current. injection(x₀, V₀) − y_norton·V₀ must equal conj(S/V). A transposed sine and cosine, a q axis defined the other way round, or a sign slip in the stator solve all survive a plausibility reading; none survives this.
  2. A machine with no subtransient saliency cancels its own Norton stamp, so ∂I/∂V is exactly zero.

Against Dynawo, gridoxide's δ₀ on Kundur's Example 13.2 agrees to 6e-5 rad — two implementations deriving the same rotor angle from the same terminal condition by independent routes.

What loads become

Constant admittance, derived at the solved voltage, unless a ZipLoad device says otherwise. This is exact at t = 0 by construction, and it is a real modelling choice with real consequences: a constant-impedance load sheds power as the square of the voltage, so it is optimistic about voltage recovery compared with a constant-power one. See The Model Library.

The Model Library

A machine and its controls are one device

An exciter writes E_fd into its machine, a governor writes P_m, a stabilizer writes v_s into the exciter, and all three read a machine state or the terminal voltage. Modelled as separate devices those couplings would be cross-device Jacobian entries — a second sparsity problem on top of the network's, for what is physically one machine.

A generating unit composes them into one device with one contiguous state block, so every coupling is an ordinary partial derivative inside one diagonal block.

The couplings are not written out per combination, which would be combinatorial. Each part declares its derivatives with respect to its own states and its own scalar input, and the unit composes them by the chain rule over the signal graph:

   Δω ──► PSS ────► v_s ──┐
                          ├──► AVR ──► E_fd ──┐
   |V| ────────────(−)────┘                   │
                                              ├──► machine ──► I, δ, ω
   Δω ──► governor ─────────────► P_m ────────┤
   V   ───────────────────────────────────────┘

The graph is acyclic and no block's output depends on its own input through another block, so one forward pass evaluates it and one sweep of the chain rule differentiates it. The governor's direct feedthrough from Δω to P_m is not a loop: P_m enters ω̇, and ω̇ does not enter P_m — only ω does.

Machines

ModelStatesWhat it adds
Classicalδ, ωConstant voltage behind transient reactance. The model the equal-area criterion is written for
Transient (4th)+ e'_q, e'_dA field winding — the first model an exciter can act on — and one q-axis damper
Salient (5th)+ ψ_1d on d, one flux on qA salient-pole rotor: a damper on each axis, but no q-axis transient — there is no q-axis field for the flux to hide in
Subtransient (6th)+ ψ_1d, ψ_2qTwo more damper windings. Immediately after a disturbance the machine's effective impedance is x'', not x'

Every machine offers both stator formulations. By default the speed-voltage terms omit the rotor speed and the swing equation is written in power — the classical RMS assumption. With speed_voltages on, the speed is carried and the swing equation is written in torque, which is what Dynawo and Sauer & Pai do. The two agree exactly at synchronous speed and part in proportion to the speed deviation; on Kundur's Example 13.2 the full form removes 96% of the disagreement with Dynawo. See Against Dynawo.

P_e throughout is the air-gap power, not the terminal power. With armature resistance the two differ by the stator copper loss, and it is the air-gap power the rotor feels. Using the terminal power gives a machine damped by its own resistance — wrong in a way that looks entirely plausible, since the swing still decays. Dynawo agrees: its PmPu exceeds its PGenPu by exactly the copper loss.

The fourth-order model's air-gap power keeps the saliency term (x'_q − x'_d)·i_d·i_q. Dropping it is a common and invisible mistake: the machine still swings, just at a slightly wrong frequency and to a slightly wrong equilibrium.

Why the subtransient conventions are derived rather than cited

Published statements of the sixth-order model disagree with each other about the sign of ψ_2q and of e'_d. A formulation copied from one source and checked against another is internally consistent and physically wrong.

So the conventions here are pinned by two requirements the code can be held to:

  1. The steady state must reduce to the fourth-order model's, term for term. Setting the flux derivatives to zero and substituting into the subtransient EMFs must turn the subtransient stator equations into the transient ones. That fixes the interpolation coefficients and the damper equations.
  2. The two axes must map onto each other under (e'_q, ψ_1d, i_d) ↔ (e'_d, ψ_2q, −i_q). That fixes the sign of the damper-coupling correction in ė'_d, which the first requirement cannot see, because that correction vanishes at steady state.

A confirmation falls out: both damper equations reduce to −Δ/T'' for the same Δ the correction terms use. One expression serving both is itself evidence the signs agree.

The gate is the reduction — take x'' up to x' and the sixth-order machine must trace the fourth-order machine's trajectory through a fault, which it does to 1e-5 rad.

Controls

KindModels
ExcitationSEXS (lead-lag into a lag), and a purely proportional regulator
GovernorTGOV1 (droop into two lags), and a purely proportional governor
Stabilizerwashout into two lead-lag stages

The two proportional variants carry no states at all. They are not simplifications of the lagged ones — they are different devices, and they are what Dynawo's VRProportional and GoverProportional implement, so a Dynawo case maps onto them exactly rather than through invented time constants. They are also what Kundur's worked examples use.

A zero-state control drops into the chain rule with no special case, which is the same property that lets a ZIP load be a device with no states.

What droop does

TGOV1's R decides how a disturbance is shared. Synchronized machines settle at one frequency deviation, and each one's steady-state contribution is then −Δω/R, so a machine with half the droop picks up twice the power. That is a system-level property no single model can state on its own, and it is gated as such.

The washout, and why a stabilizer is safe to add

A stabilizer adds a small signal in phase with rotor speed to the exciter's input, to damp the electromechanical oscillation a high-gain exciter erodes. Its washout passes no steady signal at all, so v_s = 0 at any equilibrium whatever the speed is, and it has no reference to latch.

That is the property that makes it safe: it cannot shift the voltage setpoint, however it is tuned, so it can only affect the transient. Note that the washout's state does not go to zero — it goes to the input. ẋ = (u − x)/T_w, so x → u, and it is the output u − x that vanishes. The state absorbing the steady component is precisely the mechanism.

Loads

The default is constant admittance from the operating point, which is optimistic: it sheds power as |V|², so it helps the voltage recover from exactly the disturbance that depressed it.

ZipLoad puts the choice in the caller's hands as three fractions, with a low-voltage cutoff. A constant-power load is singular at zero voltage — I = conj(S)/conj(V) diverges, and a nearby fault drives it there — so all three parts are written in one form

I = conj(S_z)·V/V₀²  +  conj(S_i)·V/(V₀·m)  +  conj(S_p)·V/m²,     m = max(|V|, V_cut)

Above the cutoff those are exactly the three exponents. Below it m freezes and the whole load becomes the constant impedance that reproduces its own current at the cutoff voltage. The current is continuous there; its derivative is not, and that kink is a real property of the model rather than an implementation artifact.

The cutoff changes answers. It is a modelling parameter, not a numerical tolerance, and it should be reported alongside a result that depended on it.

Limits, and why they are non-windup

An exciter ceiling, a governor valve limit and a stabilizer output clamp are all implemented, and both readers carry them through from their files.

They are non-windup, which is the distinction that matters. With windup the state keeps integrating past the boundary while the output is pinned there, so when the error finally reverses the output stays pinned for however long the state takes to travel back — a delay with no physical basis. On the gated case an unlimited exciter reaches 18 pu against a 2.6 pu ceiling; a wound-up one would sit at that ceiling for as long as it took to fall back through fifteen per unit. Held at the boundary, the state never exceeds it at all.

Three pieces make that work, and each answers a real failure:

The active set is fixed for the duration of a step. Recomputing it from each Newton iterate makes the residual non-smooth inside the solve: an iterate landing just above the boundary sees a zeroed derivative, the next lands just below and sees the full one, and the two alternate. Measured, that made an ordinary exciter ceiling fail the step outright. Frozen, the step is smooth and the Jacobian is exact for what is actually being solved. A limit therefore engages one step late, which at five milliseconds is not worth the machinery to avoid.

States are projected onto their limits after each accepted step. The step that crosses a boundary can still carry a state past it, because the trapezoidal rule averages a start-of-step derivative that was still driving hard with an end-of-step one that has been zeroed — and a frozen derivative cannot then bring it back. The projection is exact rather than a correction: a non-windup state has no legitimate value outside its limits.

Rate limits are implemented too, on the governor valve, and they constrain a different thing: not where the valve may be but how fast it may travel. A steam valve that opens in a fifth of a second and a hydro gate that takes five are the same model with different rates, and the difference decides whether a machine can arrest a frequency excursion at all.

The two compose in one order and not the other. The rate limit clips the derivative first; the position limit then decides whether the valve may move at all. Reversing them would let a valve pinned at its ceiling still "travel" at its rate limit, which is nothing.

What remains genuinely absent is saturation — see the readers.

Every model is checked against an oracle

The analytic Jacobian is what runs; a central-difference oracle is what proves it. Every model, in every combination of controls, is checked at points well away from the equilibrium — a Jacobian that is only right at the operating point is right for nothing.

Events and Discontinuities

Why an event is not just another step

At an event the algebraic variables jump and the differential ones do not. A rotor angle cannot change discontinuously — it is the integral of a speed — but a bus voltage can and does, because the network equations carry no derivative and simply state what V must be right now, given x and the present topology.

So the integration rule must not be applied across the discontinuity. Doing so would average a pre-fault derivative with a post-fault one over a step in which neither held, which approximates nothing. The handling instead:

  1. Step exactly onto the event time.
  2. Apply the change and reassemble the Y-bus.
  3. Hold x fixed and re-solve Y V − I_inj(x, V) = 0 for V alone.
  4. Resume, with a couple of backward-Euler steps to damp the ringing the trapezoidal rule would otherwise show.

Step 3 is done by taking an ordinary step with h·a = 0: every differential row then reads x₁ − x₀ = 0, pinning x exactly, while the algebraic rows are untouched. One assembly, one code path, no separate pattern for the sub-block.

Both the pre-event and post-event points are recorded, at the same time value, so the discontinuity is visible in the trajectory rather than smoothed away by whoever plots it.

The states are pinned, not merely left alone

With h·a = 0 the Jacobian is block lower triangular, so in exact arithmetic the state update is zero. It is not zero in floating point: no backend does forward substitution in that order — each applies its own fill-reducing permutation and partial pivoting — and roundoff leaks across the block boundary, measured at ~1e-10 rad on a faulted network whose conditioning the fault admittance dominates.

Small, but wrong in kind and cumulative over a run with many events. A rotor angle is defined to be continuous across a discontinuity, so the algebraic re-solve declines to apply the update at all, and the gate asserts bit-identity rather than a tolerance.

The events

KindEffect
BusFaultAdd a shunt admittance to ground. Replaces any fault already standing at that bus rather than adding to it
ClearFaultRemove it
BranchTrip / BranchCloseTake a branch out of service, or put it back
UnitTrip / UnitCloseDisconnect a generating unit, or reconnect it
LoadStepChange a bus's load

All of them are value-only. None changes the set of unknowns, so none forces a re-analysis, and one symbolic factorization serves a whole run. See the DAE chapter for how the topology-superset pattern buys that.

A "bolted" fault is a large admittance rather than an infinite one, because an infinite admittance is not a number. 1e6 per unit against a network whose entries are order 1 leaves |V| around 1e-6, four orders below any tolerance that matters. Going larger buys nothing and costs conditioning.

A LoadStep is applied as a change of admittance, not of power — Δy = −conj(Δs)/|V₀|² at the voltage the case was initialized at — because that is what the loads in this system are. It therefore delivers exactly Δs if the voltage happens to be back at V₀, and less, in proportion to |V|², if it is not.

A tripped unit is frozen, not removed. Nothing in the network can observe a disconnected machine's rotor, so integrating it would track a quantity no result depends on, and reconnecting it properly would need synchronization, which is not modelled.

Timing

Steps are truncated to land exactly on each event time, and the time is then snapped to the event's own value rather than accumulated — so an event time need not be a multiple of the step, and does not drift with the number of steps that preceded it.

No root-finding is needed for a scheduled event: it is already a time. A relay is different.

Relays: events triggered by a state

A relay watches a quantity — a bus voltage, a machine's speed, its angle excursion — and acts if it stays out of bounds for a stated delay.

The delay is the whole design. A relay that acted the instant a threshold were crossed would trip on every fault in the network rather than the ones it is meant to clear. And because the delay is measured from the crossing, an error in when the crossing happened is an error in when the relay acts — so the crossing is located, not rounded to whichever step happened to notice it. The step is shortened onto it with the same Illinois locator continuation uses to find a reactive limit along its curve, and from that instant the relay's action is an ordinary scheduled event.

A relay whose condition stops holding forgets. That is what separates it from a stopwatch, and it is what makes a fault cleared inside the delay trip nothing. It is gated as such: the same fault held for 0.2 s trips nothing and held for 0.9 s trips, against a 0.4 s relay.

One subtlety is worth naming, because getting it wrong is silent. A condition can begin holding at an event — a fault collapses a voltage within the instant — and then both ends of the following step are already inside the trip region, so a comparison local to that step sees no crossing at all and the relay never fires. What is compared is therefore the value at the last accepted point, remembered across events, rather than the step's own start.

Watching an algebraic quantity and watching a differential one differ in kind, and the gates cover both: a bus voltage moves discontinuously at an event, so its crossings tend to sit exactly on event times, while a rotor speed is integrated and crosses somewhere strictly inside a step. The located time reproduces the threshold to 1e-7 on a 20 ms step.

Backward-Euler damping, and what it costs

The trapezoidal rule is A-stable but not L-stable, so a discontinuity excites a numerical oscillation at the fastest mode that decays only as (−1)^k. Two backward-Euler steps after each event annihilate it.

The cost is exact and known: backward Euler evaluates δ̇ at the end of the step, so on a linearly growing speed each step overshoots by Ω_b·a·h²/2 with a = P_m/2H. Two steps leave a permanent offset of Ω_b·a·h² — 2.5e-5 rad at a 1 ms step on a typical machine — and it falls as h² despite backward Euler being first-order, because only a fixed number of steps ever use it.

Both of those numbers are gated against their closed forms, on a case where the exact trajectory is known: with P_e removed by a terminal fault the acceleration is constant, the speed is linear, and the angle is exactly quadratic — which the trapezoidal rule integrates with no error at all.

Islands

A switching event can leave a group of buses with no machine and no fixed-voltage bus in it. Such an island still solves — every bus has a path to ground through its own load — but the answer is a de-energized island, not a dynamic one, and reading it as a trajectory would be a mistake. The run reports it rather than presenting it.

Small-Signal Analysis

A different question

A time-domain run answers "what happens after this disturbance". Small-signal analysis answers one no single run can: what dynamic character does this system have at all? Which oscillations exist, how fast each decays, and — the part that makes it actionable — which machines are taking part in each.

It is also the only reliable way to find a negatively damped mode. A run finds one if the disturbance happens to excite it; an eigenvalue finds it whether anything excited it or not.

The reduction

Linearize the DAE about an equilibrium:

Δẋ = A_x·Δx + A_v·ΔV
0   = C_x·Δx + C_v·ΔV

The algebraic half carries no derivative, so it is a constraint on ΔV rather than a state of its own — which means it can be eliminated outright:

\[ \Delta V = -C_v^{-1} C_x \Delta x, \qquad A = A_x - A_v C_v^{-1} C_x \]

That reduced A is the whole dynamic system, of dimension the differential states alone. The network contributes to every mode without contributing a single one of its own — which is the same statement, seen from a different angle, as the one that makes this an RMS simulation rather than an EMT one.

The four blocks are not new code

They are the very blocks DaePattern::fill already assembles for every Newton iteration of every step, read out at h·a = 1:

⎡ I − A_x   −A_v ⎤     so   A_x = I − (top-left),   A_v = −(top-right)
⎣   C_x      C_v ⎦          C_x and C_v as they stand

That is worth more than the saved code. Two hand-written derivations of the same Jacobian would be two things to keep in step, and a small-signal analysis that had quietly drifted from the simulation it describes would be worse than none. There is one Jacobian, and it is the one already checked against a finite-difference oracle for every model in the library.

Participation factors

An eigenvalue says a mode exists. A participation factor says whose it is:

\[ p_{ki} = \frac{|u_{ki},w_{ik}|}{\sum_j |u_{ji},w_{ij}|} \]

with u the right eigenvectors and w = u^{-1} the left ones. The product is dimensionless and scale-free, which is what lets a rotor angle in radians and a field voltage in per unit be compared at all.

This is the output that gets used. A poorly damped mode at 0.8 Hz with 90% participation from two machines' speeds is an inter-area oscillation between them, and it names the machines to tune.

Mode shape

A participation factor says whose a mode is. It cannot say how they move, and for an oscillation that is the more useful half. The mode shape is the right eigenvector read at the rotor angles, rotated and scaled so the largest component is 1∠0:

G1.delta 1.00∠+0°, G2.delta 1.00∠-180°

Two rotors near opposite phase are swinging against each other — an inter-machine or inter-area mode. Two near the same phase are riding together, which is a different phenomenon needing a different remedy.

Participation cannot make that distinction. On two islanded machines it says both rotor modes belong to the rotors, which is true of both and useful about neither; the shape separates the oscillation from the free drift of the island's absolute angle, and that drift is reported with no shape at all, because a relative phase between things that are not oscillating means nothing.

What it looks like

$ gridoxide dynamics case.json --modes 6

13 mode(s) over 13 differential state(s)
every mode, by a dense decomposition of the state matrix

                eigenvalue    damping   freq (Hz)    tau (s)   residual   participation
    -0.73018     +7.99385j     0.0910      1.2723      1.370      0.0e0   G1.omega 41%, G1.delta 36%, G1.eqp 6%
    -0.73018     -7.99385j     0.0910      1.2723      1.370      0.0e0   G1.omega 41%, G1.delta 36%, G1.eqp 6%
    -1.02972     +2.23736j     0.4181      0.3561      0.971      0.0e0   G1.avr_lead 37%, G1.eqp 34%, G1.delta 7%
    -1.02972     -2.23736j     0.4181      0.3561      0.971      0.0e0   G1.avr_lead 37%, G1.eqp 34%, G1.delta 7%
   -40.55915     +5.54508j     0.9908      0.8825      0.025      0.0e0   G1.psi1d 39%, G1.pss_lead2 19%, G1.pss_lead1 19%
   -40.55915     -5.54508j     0.9908      0.8825      0.025      0.0e0   G1.psi1d 39%, G1.pss_lead2 19%, G1.pss_lead1 19%

every mode decays

Three distinct physical mechanisms, each named by the states it belongs to: the electromechanical swing at 1.27 Hz on the rotor, an excitation-and-field-flux mode at 0.36 Hz, and a fast mode on the damper winding and the stabilizer's lead stages. None of that has to be inferred from a trajectory.

Gates

The load-bearing one is that the eigenvalue agrees with the closed form the time-domain run is independently checked against. For an undamped classical machine against an infinite bus the swing mode is exactly

\[ \lambda = \pm j\sqrt{\Omega_b K_s / 2H}, \qquad K_s = P_{max}\cos\delta_0 \]

and both halves are checked: the frequency, and that the real part is zero, since nothing dissipates.

Two more compare against the run itself, through almost no shared code — one integrates a trajectory and counts zero crossings, the other assembles a Jacobian, eliminates the network and takes an eigenvalue:

  • the predicted period matches the observed one to 2e-3 relative;
  • the real part predicts the decay: successive peak amplitudes fall as exp(σ·Δt), to 2%.

And a fourth-order machine's modes separate the way they should: the swing mode is over 80% the rotor's, and the slowest real mode is the field flux with a time constant of the order of T'_d0.

Analysing a point the system is not sitting at is refused. A linearization about a mid-transient state describes nothing in particular, and its eigenvalues would look entirely plausible.

Targeting a region

Everything above computes every mode, by forming A and decomposing it whole. That is O(n³) in the states and O(n²·n_net) in the reduction, and it is the right method up to a couple of thousand states. A four-thousand-bus case has twenty thousand of them, where it is not slow but hours — and almost none of what it computes is wanted.

The question a stability study actually asks is narrower: the modes near this frequency, or the least damped ones, six or ten of them. Naming where to look is what makes that affordable:

$ gridoxide dynamics ring4096.json --modes 4 --modes-freq 1.0

4 mode(s) over 20480 differential state(s)
the 4 nearest -0.3146+6.2832j (1.000 Hz) — sparse Arnoldi, 3 restart(s). NOT every mode the system has.

                eigenvalue    damping   freq (Hz)    tau (s)   residual   participation
    -1.02387     +5.82374j     0.1732      0.9269      0.977    2.5e-14   G2068.omega 0.14%, G2070.omega 0.14%, G2066.omega 0.14%
                                                    shape: G2062.delta 1.00∠+0°, G2060.delta 1.00∠+21°, G2064.delta 1.00∠-21°, G2058.delta 1.00∠+42°
    -1.03416     +5.83962j     0.1744      0.9294      0.967    2.5e-14   G2052.omega 0.14%, G2054.omega 0.14%, G2050.omega 0.14%
                                                    shape: G2048.delta 1.00∠+0°, G2050.delta 1.00∠-21°, G2046.delta 1.00∠+21°, G2052.delta 1.00∠-42°

Twenty thousand differential states, on a case the dense method cannot begin.

Read the second line. A partial list of modes supports no statement about the system as a whole: "nothing unstable here" means near this shift, and the output says which kind of list it is rather than leaving a reader to remember. The Python binding carries the same distinction as result.complete.

Then read the shape. Equal magnitudes with the phase advancing 21° from one rotor to the next is a travelling wave going round the ring — which is what a ring of near-identical machines should have, and is not something a participation factor could have told you: every machine takes part, at 0.14% each, and the interesting content is entirely in the phases.

How the sparse method works

Shift-invert. The eigenvalues of \((A - \sigma I)^{-1}\) are \(1/(\lambda_i - \sigma)\), so the ones nearest σ become the largest — and largest is what a Krylov method finds first. Aiming the analysis is choosing σ, and because the modes worth aiming at oscillate, σ is complex: --modes-freq 0.5 is the shift \(-\zeta\omega_d/\sqrt{1-\zeta^2} + j\omega_d\).

A is never formed. Write the linearized DAE as a pencil rather than a reduced matrix:

 ⎡ A_x  A_v ⎤ ⎡x⎤       ⎡ I  0 ⎤ ⎡x⎤
 ⎣ C_x  C_v ⎦ ⎣v⎦ = λ   ⎣ 0  0 ⎦ ⎣v⎦
        J                    E

Its finite eigenvalues are exactly A's — the bottom block-row carries no λ, so it reads C_x x + C_v v = 0 whatever λ is, which is the elimination written down rather than performed. Then for a vector b in the state space,

\[ S(b) = \text{the leading } n_x \text{ entries of } (J - \sigma E)^{-1} \begin{bmatrix} b \\ 0 \end{bmatrix} \]

is \((A - \sigma I)^{-1} b\), by the Schur-complement identity. Each application costs one sparse solve on the bordered system, against a factorization computed once per shift.

And the shift is free. J - σE is the same assembly DaePattern::fill produces at h·a = 1, with its top block-rows negated and (1 − σ) added on the state diagonal — and that diagonal is structurally present already, because the pattern carries a dense ∂f/∂x block per device. A shift adds no fill-in and changes no pattern, which is the same property that makes every event in a run value-only.

Left eigenvectors — which participation factors need, and which the dense method gets by inverting U — come from a second Arnoldi pass on the adjoint operator, against the same factorization. The two passes converge from different subspaces and are matched by eigenvalue; where that match is ambiguous, the mode is reported without participation rather than with a plausible wrong one.

Two things measured on the way are worth carrying:

  • A restart means growing. On a ring of two thousand machines the electromechanical modes are packed into a tenth of a hertz, and restarting from a combination of Ritz vectors at a fixed Krylov dimension barely helped — five restarts at dimension 32 moved the residual from 6e-2 to 7e-3. Raising the dimension to 128 converged to 2e-19 in one cycle, and faster in wall time. So a restart doubles the dimension, up to a cap set by memory.
  • A residual is not an error bar. It is measured by applying the operator rather than by the textbook estimate |h_{m+1,m}||e_m^T y|, which reported 8e-52 where the truth was 1.3e-14. But even a true residual only certifies that the pair solves the problem it claims to. How far the eigenvalue could still move is the eigenvalue's condition, 1/|w^H u|, and on that same ring the forward and adjoint passes disagree at the 1e-4 level with residuals at 3e-14 — not a defect in either, but the honest accuracy available for modes that ill-conditioned.

Eigenvalue sensitivities

An eigenvalue says a mode is poorly damped. A sensitivity says what to change:

\[ \frac{d\lambda}{dp} = \frac{W^H (\partial J/\partial p), U}{w_x^H u} \]

in the pencil's own left and right eigenvectors — which avoids differentiating C_v^{-1}, since ∂J/∂p touches only the rows of the device that owns p. It is reported as dλ/dp and, more usefully, as dζ/dp: how much damping ratio a unit of the parameter buys.

$ gridoxide dynamics case.json --modes 3 --modes-sensitivity

sensitivity of the least-damped mode (-0.73018+7.99385j, ζ = 0.0910):
         parameter       value                  dlambda/dp    dzeta/dp  dzeta per 1%
              G1.h      5.0000      +0.10135     -0.83398j   -3.110e-3     -1.555e-4
              G1.d      1.0000      -0.05231     -0.00156j    6.480e-3      6.480e-5

∂J/∂p is a central difference of the assembly — set the parameter, refill the pattern, subtract — so nothing is re-derived and nothing can fall out of step with the models as they change. The pattern does not move, because sparsity depends on structure and not on values.

Only the inertia and the damping coefficient are offered, and the restriction is the honest part rather than an omission. The formula above holds the operating point fixed, so it is the whole derivative only for parameters the equilibrium does not depend on. H appears only as 1/2H in the swing equation and D only multiplying (ω − 1), which is zero at rest. A reactance is different: initialization picks δ, e'_q and the flux states from the reactances, so changing one moves the point being linearized about, and the true derivative carries a ∂J/∂x · dx/dp term this does not have. Offering it would produce a number that looks like an answer and is a fraction of one.

Scope

Both methods are available and both say which one answered. The dense one computes every mode and is the default below two thousand states; past that, or whenever a shift is named, the sparse one computes the modes near a point. Sensitivities work from either.

Not here: the EMT half of modal analysis, which is a different simulation entirely; block Arnoldi for genuinely degenerate spectra — a ring of identical machines has near-exact repeated modes, and a Krylov space holds one vector per invariant direction, so one of each pair is found and the multiplicity is not; and sensitivities to network parameters, which perturb through the Y-bus rather than through a device's own fill.

Reading Dynamic Data

Three readers, all producing the same internal representation. Nothing downstream of them knows which format a case arrived in.

gridoxide JSON

The native format, and the one the other two target. A dynamics section alongside the ordinary buses and lines:

{
  "buses": [ … ], "lines": [ … ],
  "dynamics": {
    "s_base": 100.0, "f_nom": 50.0,
    "fixed_buses": [2],
    "units": [
      { "id": "G1", "bus": 0,
        "machine": { "model": "gen_round", "h": 5.0, "d": 1.0, "ra": 0.003,
                     "xd": 1.8, "xq": 1.7, "xdp": 0.30, "xqp": 0.55,
                     "xdpp": 0.22, "xqpp": 0.25, "xl": 0.15,
                     "td0p": 8.0, "tq0p": 0.4, "td0pp": 0.03, "tq0pp": 0.05,
                     "mbase": 100.0 },
        "avr": { "model": "sexs", "k": 200.0, "ta": 0.1, "tb": 1.0, "te": 0.05 },
        "gov": { "model": "tgov1", "r": 0.05, "t1": 0.5, "t2": 1.0, "t3": 5.0, "dt": 0.0 } }
    ],
    "loads": [ { "id": "L1", "bus": 1, "zip": [0.3, 0.3, 0.4], "cutoff": 0.5 } ],
    "events": [
      { "kind": "bus_fault",   "t": 1.0, "bus": 1 },
      { "kind": "clear_fault", "t": 1.1, "bus": 1 },
      { "kind": "branch_trip", "t": 1.1, "branch": 1 }
    ],
    "relays": [
      { "id": "uv1", "watch": "bus_voltage", "bus": 1,
        "when": "below", "threshold": 0.7, "delay": 0.2,
        "action": { "kind": "branch_trip", "branch": 1 } }
    ]
  }
}

The model parameter blocks are the models' own parameter structs. That is deliberate: the file format cannot drift away from what the models take, because a renamed field is a missing-field parse error rather than a silently defaulted zero.

A relay's action and a scheduled event are spelled the same way — one vocabulary for what can happen, whether a time or a threshold decides when. A relay carries no time at all: see Events and Discontinuities.

speed_voltages (default false) chooses the machine formulation for the whole case — see The Model Library.

fixed_buses names buses held at constant voltage for the whole run — an infinite bus, represented exactly rather than approximated by a large inertia or a small source impedance. It is optional: a system with no fixed bus is perfectly well posed, since every machine's rotor angle is an absolute state and the whole system's frequency is free to move.

A device may state its own p and q; omitting both makes it take whatever its bus's solved injection has left. See Initialization for why that matters.

PSS/E .dyr

The format most published dynamic cases ship in, and the one ANDES reads — so the same file can drive both gridoxide and a reference, which removes a class of "the two tools were given different data" disagreement before it starts.

Free-format records terminated by /, spanning any number of lines:

     1 'GENROU' '1 '  8.0  0.03  0.4  0.05  5.0  1.0
                      1.8  1.70  0.30  0.55  0.22  0.15  0.1  0.3  /
     1 'SEXS'   '1 '  0.10  1.00  200.0  0.05  -5.0  5.0  /

A .dyr cannot stand alone. Two things every machine model needs are simply not in it — the MVA rating and the armature resistance, which are the .raw's MBASE and ZSORCE — and neither is the bus numbering. All three are demanded from the caller rather than invented, and the error message says where they live.

Two parsing details matter. A record ends at the /, not at the newline, so a three-line GENROU is one record with fourteen parameters rather than three truncated ones. And a leading / is a comment only when no record is open — mid-record the same character is the terminator, and files do put it on a line of its own.

SEXS's first field is the ratio T_a/T_b, not a time constant. Misreading it gives a plausible exciter an order of magnitude too fast.

Dynawo .dyd / .par / .crv

Dynawo is MPL-2.0, RTE-maintained, ships validated cases, and reads IIDM — which gridoxide's IIDM importer already handles. A .dyd lists blackBoxModel entries naming a Modelica lib, a parId into a .par, and optionally a staticId tying it to an IIDM element; a .par holds named parameter sets; a .crv says what to record.

Because a .par is addressed by parameter name, the only real risk is looking up a name no file uses — and a hand-written fixture would agree with whatever the reader happened to expect. So the test fixtures are copied verbatim from Dynawo's own repository, and the gates assert against the file's own literal values.

GeneratorSynchronousFourWindings* maps onto the sixth-order machine parameter for parameter, with generator_SNom as the machine base. ...ProportionalRegulations maps onto the proportional regulator and governor exactly. GeneratorSynchronousThreeWindings* maps onto the fifth-order salient-pole machine — its parameter set carries no XpqPu and no Tpq0, so the reader keys on the library name rather than on which parameters happen to be present, which is the difference between reading a model and guessing one from its data.

A whole case, in one call

iidm::dynamics::load_case reads both halves together — the IIDM network through gridoxide's IIDM importer, the models through the reader above — and returns a system ready to integrate. The staticId on each blackBoxModel is the correspondence between them. The joining itself is dyd::case_from_network, which takes the static network as a dyd::StaticNetwork and so does not care which format it came from.

Each machine's own terminal power is recovered exactly rather than apportioned. A power flow produces a bus's total; the IIDM states every load's p0 and q0, and those are precisely what went into that bus's specification, so

machine's injection = bus's solved injection − the loads the file states there

holds for both active and reactive power, at a PV bus as much as a PQ one. That closes for a Dynawo case the one hazard no downstream gate can catch — a wrong device/load split is self-consistent and therefore silent. Two machines on one bus is the case it cannot resolve: their share of the bus's solved reactive power is genuinely not in the file, only the total is, so it is refused by name.

Tap-changing load models, and anything else attached to a static element that is not a generator, are treated as static and named — the network is right, but their dynamics are not there.

One conversion is inferred rather than read, and is flagged in the source: governor_KGover is a gain on the machine's own governor_PNom, while the proportional governor wants one on the network base, so the reader applies k = KGover · PNom / s_base.

Limits are carried through

SEXS's EMIN/EMAX, TGOV1's VMIN/VMAX, Dynawo's voltageRegulator_EfdMinPu/MaxPu and its governor_PMin/PMax all reach the models. Two details are worth knowing: TGOV1 states VMAX before VMIN, so reading them in field order gives a valve limited upside-down that therefore never moves; and Dynawo's power limits are in MW, so they divide by the network base while its field-voltage limits go through unchanged — which is sound because gridoxide's own initialization reproduces Dynawo's efdPu exactly.

Saturation, stated twice and incompatibly

PSS/E gives two points on a curve, S(1.0) and S(1.2). Dynawo gives an exponential characteristic, md/mq/nd/nq. The two are not convertible without committing to a curve shape, and no model here implements saturation. Both readers parse it, neither uses it, and both report a nonzero value — because ignoring it changes answers, and a silent drop is exactly what surfaces later as a small unexplained disagreement with a reference.

Kundur's Example 13.2, Against Dynawo

Every other check in this module is self-supporting: a closed form, an identity, a reduction, an oracle. Those catch a great deal, but there is one thing they cannot catch — a formulation that is internally consistent and physically wrong. For that you need a second implementation.

The case, and why this one

Kundur's Power System Stability and Control Example 13.2, as Dynawo ships it: a sixth-order machine with no regulators, an infinite bus, two parallel lines, a fixed-ratio transformer, a bolted fault at the line junction and a line trip on clearing.

It was chosen because every element is something gridoxide models exactly — no tap changers, no limits, no saturation, no fifth-order machine. Nothing has to be excused before the comparison starts, which is what makes a disagreement mean something.

Dynawo's repository ships its own solver's reference curves alongside the case, so the comparison needs no Dynawo install — the same arrangement tests/data/ucte/ and tests/data/iidm/ already use for pypowsybl.

What the reference file settles before a single step

Two things, checked first because a disagreement in either would make the rest meaningless:

  • theta at t = 0 is 1.224004 rad. gridoxide's own initialization, from an entirely independent derivation, puts δ₀ within 6e-5 of it. That is the rotor-frame convention agreeing with a second implementation — much stronger evidence than any self-consistency check.
  • PmPu is 0.903 while PGenPu is 0.900 on the machine base. The difference is exactly the 0.003 pu stator copper loss, which is the statement that Dynawo's mechanical power, like gridoxide's, is the air-gap power.

The operating point is checkable by hand against the book, too: P = 0.9 pu and E_t = 1.0∠28.34°, and gridoxide's power flow reproduces the file's stated terminal angle of 0.49445 rad to 1e-4.

The agreement

Rotor angle through the fault9.1e-4 rad
Rotor speed through the fault6.6e-5 pu
Terminal voltage during the fault9.0e-4 pu
Rotor angle over the first swing1.9e-2 rad
The published 70 ms clearing timesurvivable in both; 500 ms in neither

The disagreement, and what it turned out to be

After clearing the two separate steadily. That was chased rather than absorbed into a tolerance, and what it is not was established first, each by experiment:

  • not step size — gridoxide's answer is converged to 1e-4 rad between h = 4 ms and h = 0.0625 ms;
  • not the nominal frequency — at 60 Hz the machine loses synchronism outright;
  • not the tripped branch — tripping the other line, or neither, gives a wholly different trajectory;
  • not the sign of the q-axis damper coupling that the model's derivation left open — flipping it moves the answer by under 1e-3 rad, which also means this case does not settle that question either way.

Localizing it needed separating the power-angle relation from the accumulated angle. At matched rotor angle in the first tenth of a second after clearing, gridoxide's terminal power is 0.6% below Dynawo's, and the voltage error steps at the clearing instant rather than growing smoothly. So it is algebraic, not numerical.

Reading Dynawo's own Modelica settled it:

udPu = (Ra + RTfo)·idPu − omegaPu·lambdaqPu
uqPu = (Ra + RTfo)·iqPu + omegaPu·lambdadPu
2·H·der(omegaPu) = cmPu·PNomTurb/SNom − cePu − DPu·(omegaPu − omegaRefPu)
PePu = cePu·omegaPu

Dynawo keeps ω on the speed-voltage terms and writes the swing equation in torque. gridoxide makes the classical RMS approximation in both places: ω ≈ 1 in the stator, and the swing equation in power. The two differ by exactly a factor of ω, so they agree at synchronous speed and part in proportion to the speed deviation — which is the observed pattern exactly: nil at t = 0, 0.04% early in the fault where ω − 1 = 0.0015, and 0.6% after clearing where ω − 1 = 0.009.

Neither form is wrong. Kundur §13.3 states the approximation explicitly; Sauer & Pai keep the terms.

Acting on the attribution

Both forms are now available — "speed_voltages": true in a document, --speed-voltages on the command line, speed_voltages=True in Python — and turning the full one on drives the discrepancy to nothing:

approximatefull form
Post-fault power offset at matched angle−0.611%−0.026%
Rotor angle over the first swing1.89e-2 rad1.76e-3 rad
Rotor angle over the whole 5 s2.06e-1 rad3.81e-2 rad

That is the proof rather than the argument. Measuring a discrepancy and attributing it by reading the reference's source is one thing; acting on the attribution and watching the number collapse by a factor of twenty-four is another. Had the ω ≈ 1 assumption not been the cause, switching it off would have moved the offset somewhere arbitrary rather than to zero.

What remains — 0.026% — is genuinely unexplained, and is bounded by a gate so it stays visible.

The approximation is still the default, for two reasons: it is what makes the phasor formulation coherent in the first place, and every closed-form gate in this crate, the equal-area criterion above all, is derived from the power form. A run that wants Dynawo's answer asks for Dynawo's model.

This is what an external gate is for: a difference invisible to every self-consistency check, because both formulations are internally perfect.

What is not yet done

A second external reference. ANDES is installed and its power flow reproduces the case — once its lines are given matching Vn1/Vn2, since it defaults them to 110 kV and silently rescales every impedance — but its own initialization then fails on the hand-built case, with residuals of 0.16 in the bus-3 angle equation, and the machine loses synchronism where both other tools keep it. That is a setup problem rather than a finding, and it is recorded as outstanding rather than reported as a result.

The State Estimation Problem

Motivation

Power flow answers a planning question: given the injections at every bus, what are the voltages? Every input is known exactly, and there is one right answer.

Operations does not work that way. What a control room has is a few hundred telemetered readings, each with its own error, arriving from equipment of varying quality — some quantities measured twice, many not measured at all, and a handful simply wrong because a transducer has drifted or a sign convention was mis-wired years ago. No state reproduces all of them, because they contradict each other.

State estimation asks the question that fits that data: which grid state best explains these measurements? It is the entry point to essentially every operational application, because those applications need a complete, consistent snapshot of the network and telemetry never provides one.

Weighted least squares

Let \(x\) be the state — every bus's voltage magnitude and angle — and \(z\) the vector of measurements. Each measurement has a measurement function \(h_i(x)\): what that sensor would read if the grid were in state \(x\). A voltage sensor's is trivial (\(h = \vert V_k \vert\)); a branch power sensor's is the terminal flow; a bus injection's is the familiar power-flow expression.

The estimate is the state minimizing the weighted sum of squared disagreements:

\[ J(x) = \frac{1}{2} \left( z - h(x) \right)^{T} W \left( z - h(x) \right) \]

with \(W = \Sigma^{-1}\) diagonal, \(W_{ii} = 1/\sigma_i^2\). Weighting by inverse variance is what makes the estimate maximum likelihood under independent Gaussian errors, and it is why a sensor's declared \(\sigma\) matters as much as its value: a reading trusted to 0.1% pulls a hundred times harder than one trusted to 1%.

Setting \(\partial J / \partial x = 0\) and linearizing gives the normal equations, solved repeatedly until the step vanishes:

\[ G , \Delta x = H^{T} W r, \qquad G = H^{T} W H, \qquad r = z - h(x) \]

where \(H = \partial h / \partial x\) is the measurement Jacobian. This is Gauss-Newton.

A worked example carries all of that through on three buses and three measurements, with every intermediate — \(H\), \(W\), \(G\), the residuals, the chi-squared statistic and the normalized residuals — small enough to check by hand.

How this differs from the power-flow Newton loop

The two loops look alike and are not the same.

The residual does not go to zero. solver::newton_raphson drives a mismatch to zero: an exact solution exists and Newton converges onto it. Gauss-Newton has no such target — the measurements are inconsistent by construction, so \(r\) stays nonzero at the optimum. Convergence is therefore tested on the size of the step, never on the residual. A converged estimate with a large \(J(x)\) has not failed to converge; it means the data disagrees with itself, which is what bad-data analysis is for.

There are no PV buses. A generator's voltage is a quantity to be estimated, not asserted. So every bus contributes a magnitude unknown, and every bus but the angle reference contributes an angle:

\[ x = \left[ \theta_0 \ldots \theta_{N-1} \text{ except the reference}, ; V_0 \ldots V_{N-1} \right] \]

giving \(n = 2N - 1\), against power flow's \(n_{angle} + n_{pq}\). Confusing the two layouts is the easiest way to produce a Jacobian that is subtly and consistently wrong, so se::jacobian::StateLayout owns the mapping and nothing else indexes by hand.

The angle reference is conditional. A network measured only in magnitudes and powers is invariant under a global phase shift, so a reference must be pinned or \(G\) is singular however many measurements there are. But that invariance disappears the moment a phasor measurement supplies an absolute angle — and pinning a reference on top of one is a false constraint that rotates the whole estimate away from the data. gridoxide pins a reference exactly when no angle is measured.

Why the normal equations, and what they cost

\(H\) is rectangular (\(m \times n\)), and every sparse backend gridoxide has is square — they are power flow's Jacobian solvers. Forming \(G = H^{T} W H\) makes the system square and symmetric, so Scalar, Block, Klu, KluNative and Pardiso all carry state estimation with no new solver abstraction. That is the single most important reuse in the design.

The price is conditioning: \(G\) squares \(H\)'s condition number. Two consequences follow, and both shape the implementation:

  • Zero injections are enforced as hard constraints rather than as very-high-weight pseudo-measurements, so the weights never span more orders of magnitude than the physics requires.
  • An orthogonal (QR) formulation, which never forms \(G\), remains the escape hatch if a real network ever proves too ill-conditioned for this path.

Validation

gridoxide is checked against power-grid-model's own state-estimation fixtures, committed under tests/data/pgm/state_estimation/ with their MPL-2.0 license files. On transmission-case — 11 buses, 4 transformers, 59 measurements — the per-unit magnitudes agree to 1.5 × 10⁻⁹.

Angles are checked in two regimes, because only one of them has an absolute answer. Where an angle is measured, absolute angles must match. Where none is, the estimate is invariant under a global rotation and power-grid-model's own fixtures do not agree with each other on the convention (transmission-case reports its source node at exactly 0, 1os2msr-no-angle reports its source node at −0.0130). The test then requires gridoxide's angles to match up to one constant shared by every bus — a stronger check than it sounds, since a wrong estimate produces per-bus errors rather than a uniform offset.

Two methods

gridoxide implements both of power-grid-model's calculation methods, and they agree with each other bus by bus on its fixtures.

SeMethod::NewtonRaphson (default)SeMethod::IterativeLinear
Solvesthe nonlinear problema linearized one, re-linearized each pass
Per iterationfresh Jacobian and factorizationright-hand side only
Convergencequadraticlinear
Accuracythe true WLS optimumapproximate — see the method's own page

The linearized method is faster per iteration and needs more of them. It is power-grid-model's default; gridoxide's default is Newton-Raphson, on the grounds that a library should be exact unless asked otherwise.

Running it

From the shell:

gridoxide estimate tests/data/pgm/state_estimation/1os2msr/input.json

From Python:

import gridoxide

model = gridoxide.StateEstimationModel.from_pgm_json("grid_with_sensors.json")
model.solve()
print(model.voltage_mag())          # per-unit, one entry per bus
print(model.observability())        # what the measurements fail to determine
print(model.bad_data())             # chi-squared, p-value, ranked suspects

Note the input needs sensors, but does not need p_specified on its loads or u_ref on its sources — those are quantities state estimation solves for rather than inputs it consumes.

Measurements and What They Mean

Everything on this page is about turning sensors into the rows of \(z\) and \(H\). It is the part of state estimation with the least mathematics and the most opportunity for a silent, confident, wrong answer — a flipped sign or a misattributed quantity does not fail, it converges to something plausible.

One measurement is one scalar

A Measurement is a single scalar observation, not a sensor. Newton-Raphson WLS treats \(\sigma_P\) and \(\sigma_Q\) as independent, so a power sensor becomes two rows; a voltage sensor becomes one or two depending on whether it carries an angle. A voltage sensor that does carry an angle is a phasor (PMU) measurement, and it is what makes the global phase determinate.

Aggregation runs in two opposite directions

Redundant sensors are merged before estimation rather than passed through as extra rows, and the two cases move opposite ways:

Several sensors observing one quantity merge by inverse variance:

\[ z = \frac{\sum_k z_k / \sigma_k^2}{\sum_k 1 / \sigma_k^2}, \qquad \sigma^2 = \frac{1}{\sum_k 1 / \sigma_k^2} \]

The result is more certain than any input. A quantity you measure twice is one you know better.

Several appliances making up one bus injection sum, and their variances sum with them:

\[ S = \sum_k S_k, \qquad \sigma^2 = \sum_k \sigma_k^2 \]

The result is less certain than its parts, because independent errors accumulate.

Conflating these is easy and costly. An early version of gridoxide's aggregation merged per bus rather than per appliance, which turned two sensors watching one load into two loads and doubled the injection. The ordering that works is: merge per appliance first, then sum across appliances at the bus.

Sign conventions are not uniform

Taken from power-grid-model's reference-direction rules, which differ by sensor type:

SensorReference directionPositive means
Branch terminalbranchpower flows from the node into the branch
Load, shuntloadpower flows from the node into the appliance (consumption)
Source, generatorgeneratorpower flows into the node
Node injectiongeneratornet injection into the node

Branch measurements pass through unchanged, since that is already the convention branch_flow::terminal_flow uses. Loads and shunts are negated to become injections.

Where gridoxide's model differs from power-grid-model's

This is the subtlety that most affects correctness, and it is not a sign issue — it is a difference in what the quantity is.

power-grid-model treats a source and a shunt as appliances at a node, so their power counts toward that node's injection. gridoxide models both structurally: a source becomes a virtual slack bus feeding through an impedance branch, and a shunt becomes a Y-bus diagonal entry. Neither therefore appears in network::power_injections at that bus at all — by Kirchhoff's current law, the net injection at a source node with no load is zero, because the source's power arrives through a branch that is part of the network.

Each of the three needs its own measurement function:

power-grid-model sensorgridoxide's model\(h(x)\)
sourcevirtual slack bus behind an impedance branchthat branch's flow, negated
shuntY-bus diagonal entry\(-\vert V \vert^2 \overline{y_{sh}}\)
node (injection)—bus injection plus both of the above

Using the plain bus injection for any of them is wrong by construction. This was found rather than predicted: tests/measurement_residual_test.rs evaluates every measurement function at the state power-grid-model published and reported a 63-sigma disagreement on exactly that quantity, with the model saying 0 and the sensor saying 2.4 p.u.

Zero-injection buses

A bus with no load and no generator injects exactly nothing. That is a property of the network, not an observation of it: no sensor, no noise, no uncertainty.

The common shortcut is to feed it in as a pseudo-measurement of zero with a very small \(\sigma\). It works, and it is why so many estimators are described as ill-conditioned — the weight matrix then spans many orders of magnitude and \(G\) squares that spread. power-grid-model ships fixtures named ill-conditioned-by-line-meshed and ill-conditioned-by-link-meshed for precisely this failure.

gridoxide enforces it as a hard equality constraint instead, via the Lagrangian stationarity conditions:

\[ \begin{bmatrix} G & C^{T} \\ C & 0 \end{bmatrix} \begin{bmatrix} \Delta x \\ \lambda \end{bmatrix} = \begin{bmatrix} H^{T} W r \\ -c(x) \end{bmatrix} \]

The augmented matrix is symmetric indefinite rather than positive definite, which rules out a Cholesky-style solver — but every gridoxide backend is a general sparse LU, so this costs assembly work only. A constraint row turns out to be the same bus-injection partials an injection measurement would produce; the difference between a constraint and a measurement is entirely in how the system consumes the row.

Two details worth knowing:

  • Which buses qualify is read off the input document, not off Bus::p_spec. A state-estimation document leaves p_specified unset, so an unmeasured load looks exactly like zero injection in the converted network while being nothing of the sort. Getting this backwards would constrain real loads to zero.
  • Sources and shunts do not disqualify a bus, because gridoxide models both structurally and neither appears in that bus's injection. The virtual slack buses are excluded, since that is where a source's unknown power enters.

The fixture that pins this down is node-injection-sensor-and-zero-injection: an injection sensor reading 0.1 p.u. on a node with no appliance attached. power-grid-model requires at least one appliance for such a sensor to mean anything, so it overrides the reading and reports both buses at exactly 1.0∠0. Without the constraint, weighted least squares fits the sensor perfectly by driving that node to \(\sqrt{2} \angle -45°\) — a 41% overvoltage on a bus with nothing connected to it, and an objective of \(4 \times 10^{-28}\). By its own criterion, a flawless answer.

The Iterative-Linear Method

power-grid-model's default calculation method, and gridoxide's optional one. It trades exactness for a much cheaper iteration.

The trick

In terms of the complex voltage vector \(\underline{U}\), the awkward part of the measurement model is that power is bilinear in voltage: \(S = U \overline{I}\). Current is not — \(I = YU\) is perfectly linear. So if the measurements were currents rather than powers, the whole estimation problem would be a single linear least-squares solve.

So they are converted. A power measurement \(S\) at a terminal whose voltage is \(U\) becomes a current measurement

\[ \underline{I} = \overline{\left( S / U \right)} \]

using the voltage from the previous iterate. A magnitude-only voltage measurement becomes a phasor by borrowing the previous iterate's angle. Each pass redoes the conversion with better voltages, and the linearization error shrinks as the angles settle.

Zero-injection buses are markedly simpler here than in the nonlinear formulation: a bus with no appliance carries no injected current, so \((YU)_i = 0\) exactly, with no linearization involved. They enter the same augmented KKT system in complex arithmetic.

Where the speed comes from

The system matrix is built from admittances and measurement weights. Admittances are fixed. The weights are nearly fixed — converting \(S\) to \(I\) divides by \(U\), so a current's variance scales as \(1/\vert U \vert^2\) — and the method assumes \(\vert U \vert\) constant for that purpose.

Granting that assumption, the matrix never changes. It is factorized once, and each iteration moves only the right-hand side. Newton-Raphson, by contrast, rebuilds and refactorizes its Jacobian every pass.

What it costs

The constant-\(\vert U \vert\) assumption, plus the interpretation of power measurements as current measurements, means this method's optimum is not exactly the weighted-least-squares optimum. power-grid-model documents the same caveat and recommends its Newton-Raphson method when precision matters. gridoxide keeps both, and defaults to Newton-Raphson on the grounds that a library should be exact unless asked otherwise.

There is also a capability difference worth knowing: a lone \(P\) or \(Q\) measurement, without its partner on the same target, is dropped. Half a complex power cannot be converted into a current. The Newton-Raphson path handles the two independently and keeps it.

Under-relaxation, and why it is safe here

The linearization inverts the voltage, and that can oscillate: an estimate that is too low produces a current that is too high, which pushes the next estimate too high, and back. The iteration then sits in a two-cycle indefinitely rather than converging.

This is not hypothetical. power-grid-model's transmission-case fixture does exactly that — the step parks at \(5.5 \times 10^{-2}\) and stays there for hundreds of iterations, neither converging nor diverging:

iteration:  1     2     3     5     10    20    50    100
step:       6.9e-2 5.2e-2 5.6e-2 5.6e-2 5.6e-2 5.5e-2 5.4e-2 5.3e-2

gridoxide backs the step off — halving a relaxation factor whenever an iteration fails to improve on the last, and letting it grow again by 1.2 whenever progress resumes — which breaks the cycle:

iteration:  1     2     3     5     10    20
step:       6.9e-2 5.2e-2 2.8e-2 6.7e-5 2.8e-6 5.9e-9

Damping is a suspicious remedy in general, because it can make a solver look convergent while walking toward the wrong answer. It is safe here for a specific reason: it changes the path and never the fixed point. iterative::tests::the_true_state_is_a_fixed_point pins that down directly — seeded at a state satisfying the measurements, one iteration leaves the residuals at zero and moves the state only by the global rotation the estimator normalizes away. So a damped run converges to the same state an undamped one would reach if it got there at all.

Relaxation engages whenever an iteration fails to improve on the last by 10%, which is not the same thing as "only on a pathological problem" — a flat start alone can trip it, and on case1354pegase it does, at iteration 3. That is exactly why the factor has to be able to recover: see Letting the relaxation recover below, where a version that could only descend turned out to be spending whole runs at half a step because of one transient.

Measured cost

examples/bench_se.rs runs both methods over the MATPOWER benchmark grids, with measurements synthesized from a converged power flow — a voltage magnitude at every bus and flows at both ends of every branch, giving a redundancy around 4x.

casebusesmeasurementsNewton-Raphsonitersiterative-linearitersspeedup
case14151250.7 ms60.1 ms167x
case1181191,0835.5 ms61.0 ms375.5x
case3003012,41913.8 ms62.3 ms416x
case1354pegase1,35511,189137 ms612.8 ms4510.7x
case2869pegase2,87025,204349 ms630.6 ms3311.4x

Both the benefit and the cost show up plainly. Newton-Raphson takes exactly six iterations at every scale — quadratic convergence — while the linearized method takes 16 to 45. It still wins on wall clock because each of its iterations is so much cheaper, and the margin widens with size, from 5.5x at 119 buses to 11.4x at 2,870: prefactorization amortizes better the larger the matrix.

The accuracy trade is equally visible. Against the state the measurements were read from, Newton-Raphson lands at ~1e-14 throughout — the data is perfectly consistent here, so it is limited only by arithmetic. The linearized method runs 8.7e-10, 1.6e-8, 8.9e-8, 5.6e-7, 1.1e-6 down that same column: still far below any real measurement noise, but degrading with size rather than holding constant. That is the linearization and the constant-|U| weighting showing through, and it is why Newton-Raphson remains the default.

Where the remaining time goes

Profiling was worth doing before optimizing, because it redirected the obvious plan. On case1354pegase the two methods spend their time quite differently:

Newton-Raphsoniterative-linear
assembly (h(x), H, gain)22%—
factorization78%~30% including all setup
per-iteration solves—~70%, over 45 iterations

The tempting optimization is a symmetric factorization: G is symmetric and a general sparse LU is being used on it, so in principle half of Newton-Raphson's 78% is recoverable. Two measurements argue against it.

First, zero-injection buses are 9-31% of buses on these grids, so the constrained KKT system is the normal case, not the exception, and it is symmetric indefinite — needing Bunch-Kaufman or a quasi-definite regularization rather than a Cholesky, which is two code paths and a perturbation of the constraints.

Second and more decisively, it would speed up the wrong method. Newton-Raphson is where gridoxide is already ahead — power-grid-model's own Newton-Raphson state estimator raises SparseMatrixError on every case from 300 buses up, where gridoxide's converges. The remaining gap is on this method — 1.5 ms against 0.7 ms on case300, 7.2 ms against 3.4 ms on case1354pegase, 18.3 ms against 8.4 ms on case2869pegase — and there the factorization is not the bottleneck at all. §7 of scripts/bench/README.md has the full table.

So the open lead for this method is its convergence rate, not its linear algebra: it needs 45 iterations here where Newton-Raphson needs 6.

Measured against power-grid-model's own iteration count

That last paragraph was a conclusion about gridoxide on its own. Comparing the same method in the two tools sharpens it into something more useful, and reverses the reading §7 of scripts/bench/README.md used to give.

Neither tool reports an iteration count through its public API, so scripts/bench/se_iterations.py obtains it the same way for both: the smallest max_iterations budget that does not fail, found by bisection. Before comparing anything it is worth knowing the two criteria are the same quantity, and they are — power-grid-model's iterate_unknown returns max over buses of |u_new − u_old| phase-normalized and loops while (max_dev > err_tol); gridoxide's raw_step is .map(|(a, b)| (a - b).norm()).fold(0.0, f64::max) over the same normalized voltages, and both default to 1e-8. The one asymmetry is that gridoxide tests raw_step × relaxation, where power-grid-model has no relaxation at all and always takes the full step.

On the documents examples/bench_se.rs --emit writes:

casebusesPGM itsgridoxide itsiterationsms per iteration
case141415312.07x0.20x
case11811818351.94x0.57x
case3003009293.22x0.62x
case1354pegase1,35410282.80x0.71x
case2869pegase2,86910333.30x0.71x

(gridoxide's counts here are the ones that stood before Letting the relaxation recover, which cuts them by about 40%.)

gridoxide's iterations are individually cheaper than power-grid-model's — by 30-40% on every case above 100 buses — and it takes about three times as many of them. The flat ~2x total is the product of those two effects pulling against each other, not evidence of a constant-factor gap in the per-iteration work. Reading a stable ratio as a per-iteration constant was the error; a stable ratio is equally consistent with two ratios that happen to be stable.

The control that makes this comparison mean anything is that both tools reach the same answer: max |Δu| between their solutions is 7.6e-9 to 5.2e-7 across these cases, i.e. agreement at their shared tolerance. Same problem, same optimum, different paths to it.

So the lever is convergence rate, confirmed rather than inferred — and specifically not the linear algebra, which is already ahead.

Letting the relaxation recover

The section above establishes two things that pull in opposite directions: the damping cannot be removed, because the undamped map does not converge at all; and the damping is what makes gridoxide take three times power-grid-model's iterations. Both are true, and the way between them is that the factor could only ever go down.

The rule was: halve whenever an iteration fails to improve on the last by 10%. Nothing ever restored it. And the flat-start transient alone trips that test — at iteration 3 on case1354pegase, before the iteration has settled into anything — so a run would spend its whole length at half a step because of one early stumble that had nothing to do with the instability the damping exists for.

Adding one clause fixes it: when the step is shrinking, by more than 25%, grow the factor by 1.2 again, capped at 1. The result on the benchmark documents:

casebeforeafterPGM
case14311915
case118352118
case30029189
case1354pegase281710
case2869pegase332010

About 40% fewer iterations, and 16-22% less wall clock — measured interleaved, old build and new, two rounds. The two do not match because a fixed setup-and-factorization cost does not shrink with the iteration count; that it is now a larger share of the total is the point of having cut the rest. The iteration-count gap to power-grid-model falls from 2.0-3.3x to 1.2-2.0x, and on case118 gridoxide is now the faster of the two outright.

The growth rate is measured rather than derived, and the measurement is the interesting part. Forcing a constant relaxation shows this map's optimum sits near 0.7 — 23 iterations on case300 against 35 at 0.5 — with the map going unstable just above it: 0.8 costs 36 iterations and 0.9 costs 80. So there is a narrow good range whose location depends on the network, which is an argument for hunting for it adaptively rather than for hardcoding 0.7. Among growth factors that all converge, 1.2 has the best worst case: 1.3 costs case2869pegase 30 iterations and 1.4 costs case14 seventy, where 1.2 needs 20 and 19.

The answer is untouched, as it must be — the_true_state_is_a_fixed_point already pins that damping changes the path and never the destination, and the estimates agree to 2.5e-9 across the change, which is below the 1e-8 the iteration is asked for.

This does not explain why power-grid-model needs no damping at all. That remains open, and the next section narrows it.

The relaxation is load-bearing, not overhead

The obvious next move from that table is to take the damping out, since power-grid-model manages without it. That does not work, and finding out why moves the problem somewhere more interesting.

Forced undamped on these same documents, the iteration does not converge slowly — it does not converge at all. The step falls for three iterations and then locks onto a constant:

iter        1        2        3        4       ...      58       59       60
step   6.09e-1  2.27e-1  1.77e-1  1.76e-1     ...  1.748610e-1  1.748610e-1  1.748610e-1

Constant to seven digits for fifty-odd iterations is a limit cycle, not a floor — the step is not shrinking at all, so the iterate is circulating rather than settling. That under-relaxation at 0.5 restores convergence places the dominant eigenvalue near −1: damping maps λ to 0.5 + 0.5λ, which sends λ ≈ −1 to ≈ 0. power-grid-model's map, on the identical document with the identical measurements and no damping at all, is stable there.

The rows responsible are the branch-terminal power ones. Dropping them from the undamped run drops the step from 1.7e-1 to 7.1e-4 — three orders of magnitude — while dropping the voltage rows instead changes nothing at all. That fits their shape: a branch row converts its reading with I = conj(S/U_at), so its right-hand side depends on the inverse of a voltage the same row solves for, and on these documents the branch rows outweigh the voltage rows by four orders of magnitude.

Two further candidate mechanisms were tested and both ruled out:

  • The |U|² weight scaling. gridoxide scales a power row's weight by the reference bus's starting |U|², where power-grid-model deliberately does not — its iterative_linear_se_solver.hpp says so directly ("the variance is not scaled as an approximation"). Removing the scaling moves the limit cycle's amplitude (1.75e-1 → 1.81e-1 on case300) and does not remove it.
  • The zero-injection KKT constraints. These are 66 to 869 buses on the cases above, and power-grid-model has no equivalent augmentation. Dropping them entirely shrinks the amplitude to 5.7e-2 and still leaves a limit cycle.

Whatever destabilizes the map is therefore in its core — the power-to-current conversion, the phase normalization, or the interaction between them — rather than in either of the two places gridoxide visibly departs from power-grid-model. That is the open question, and it is worth answering before anyone reaches for an accelerator: a scheme layered on an unstable map inherits the instability.

Note this is a different measurement set from the one the next section traces. These documents carry voltage magnitudes and branch flows only; examples/se_converge.rs synthesizes bus injections too, and its undamped run decays rather than cycling. Both are real, and the difference between them is itself a clue about which rows drive the instability.

What actually ends the iteration

Tracing that convergence rate is worth doing before trying to improve it, because it is not one phenomenon but two, and the second one is not a rate at all.

examples/se_converge.rs re-runs the estimate at increasing max_iter and prints the step, its ratio to the previous step, and the error against the state the measurements were read from:

cargo run --release --example se_converge case1354pegase

Forced undamped, case1354pegase decays geometrically at a strikingly stable ratio — 0.757, holding to three digits from iteration 30 through iteration 53. A single dominant mode, textbook material for Aitken or Anderson acceleration. Then it stops:

iter        50       51       52       53      ...      197      198      199      200
step    6.3e-6   4.7e-6   3.6e-6   2.7e-6      ...   2.5e-7   3.4e-7   2.9e-7   7.3e-8

Out to 200 iterations the raw step never reaches the 1e-8 tolerance; it settles on a floor around 1e-7 and bounces. Forcing any constant relaxation between 0.4 and 1.0 gives the same picture.

What ends the default run is the relaxation ratchet:

iter      1        2        3     ...      39       40       41       42       43       44       45
raw    1.19e0   1.66e0   1.72e0   ...   3.2e-7   3.7e-7   2.1e-7   2.5e-7   2.6e-7   4.7e-7   1.9e-7
relax    1.0      1.0      0.5    ...      0.5     0.25     0.25    0.125   0.0625  0.03125  0.03125

Relaxation engages at iteration 3, in the flat-start transient, and holds at 0.5 through iteration 39. Then the raw step stops falling, the 10%-improvement test fails four times over the next five iterations, and the factor halves to 1/32. Convergence is declared at iteration 45 on a reported step of \(1.86 \times 10^{-7} \times 0.03125 = 5.8 \times 10^{-9}\).

That reported step is honest about what it measures — the state genuinely moved that little, because that is how far damping let it move. But it is not the iteration reaching 1e-8. The tolerance is met by the damping factor.

The answer is unaffected, and this is the part worth being clear about. The error against the true state bottoms out at 5.5e-7 by iteration 36 — the same 5.6e-7 this method scores on case1354pegase in the table above, i.e. its own linearization bias, the floor no amount of iterating can go below. Iterations 37 to 45 buy no accuracy in any case.

Two consequences for whoever picks this up next:

  • Accelerating the 0.757 mode wins iterations 3 through 39 and nothing after. The floor is a separate phenomenon and would still be there.
  • A step floor near 1e-7 on a gain matrix whose weights span 1e4 to 1e6, with exact KKT constraints augmented in, is the shape of linear-solve accuracy rather than of the fixed-point map — worth testing with iterative refinement or a scaling pass before assuming the iteration is at fault.

An earlier version of this section reported roughly eighty iterations, with relaxation holding at 1.0 until iteration 78. Neither figure reproduces at HEAD; the traces above are what the current code does.

Agreement with Newton-Raphson

On every fixture gridoxide checks, the two methods agree to \(10^{-6}\) per-unit, bus by bus — a stricter statement than each agreeing with power-grid-model, since it holds at every bus rather than only where an expected value was published.

FixtureIterationsMax \(\Delta \vert V \vert\) vs power-grid-model
1os2msr113.4e-10
1os2msr-no-angle115.7e-10
inf-measurement-with-injection24.0e-9
transmission-case204.3e-9
node-injection-sensor-and-zero-injection14.4e-16

Selecting it:

gridoxide estimate grid.json --iterative-linear
model = gridoxide.StateEstimationModel.from_pgm_json(
    "grid.json", method="iterative_linear", max_iter=100
)

The larger iteration budget is deliberate: this method converges linearly where Newton-Raphson converges quadratically. Spending more iterations to make each one far cheaper is the entire point.

Observability and Bad Data

A converged estimate is not a correct one. Weighted least squares will absorb a broken sensor by bending the state toward it, and will happily return an answer for a network where half the quantities were never determined at all. Both failures look exactly like success. These two analyses are what distinguish them.

Observability: what the measurements determine

An unobservable system is not a numerical accident, and treating it as one produces the least useful diagnostic available — a factorization that failed, with no indication why. The useful answer names the unknowns nobody is watching.

Two things determine state, and the analysis has to count both.

Constraints determine state too. A zero injection is not a measurement, but it is information — se::constraints puts its rows into the very system the estimator solves, so a state those rows determine really is estimated. analyze used to look only at \(H\), and would call such a state unobservable: a diagnosis that sends the user hunting for a sensor they do not need. It now takes the constraint set and folds those rows into the rank computation at unit weight — the magnitude is irrelevant, since \(\operatorname{rank}(H^{T}WH) = \operatorname{rank}(H)\) for any positive \(W\), and a hard equality is a measurement of unbounded weight.

That was already wrong on ordinary data. It becomes acute on node-breaker models, where a merged group's internal structure is entirely constraint-determined. Measured on MiniGrid's node-breaker view: 37 of its 45 buses are constrained rather than sensed, and dropping the constraint rows takes the same sensor set from observable to not.

A de-energized bus is not an unobservable one. Its unknowns are zero columns of \(H\) — nothing measures a node that carries no voltage, and no sensor plan would change that. But it is not undetermined: every estimator path here reports it at exactly zero, which is power-grid-model's own energized: 0 convention. analyze reports those unknowns separately, in de_energized, and leaves them out of both the rank test and the structurally_unmeasured list. Folding them in would call every CGMES model with one switched-out node unobservable — the same mistake as ignoring the constraint rows, in the same function.

There are two distinct failures.

Structural. A column of \(H\) that is identically zero: no measurement function mentions that unknown at all. The estimator has to find these anyway, since they make \(G\) singular on their own. In gridoxide they are usually the virtual slack buses synthesized per source — a network power-grid-model considers fully observable can still leave gridoxide with surplus unknowns, because PGM has no such bus in its state space.

Numerical. A column that is present but linearly dependent on the others. Two branch-flow measurements on a radial feeder with no injection measurement between them constrain the same combination of angles twice and leave another combination free. Nothing about the sparsity pattern gives this away; it takes a rank computation.

\(G = H^{T} W H\) is symmetric positive semidefinite by construction, and the standard rank-revealing factorization for that class is a Cholesky with symmetric diagonal pivoting: take the largest remaining diagonal at each step, stop when it falls below tolerance, and the number of steps is the rank. gridoxide uses faer's blocked implementation for the factorization but makes the rank decision itself — faer stops at \(\varepsilon n\), around \(10^{-16}\) relative, which is the threshold for "not numerically positive definite". Observability wants a much looser one: a direction determined only at the \(10^{-12}\) level is not usefully determined, and calling it observable would be the more misleading answer.

The cost is that \(G\) is densified, making this \(O(n^3)\) time and \(O(n^2)\) memory. The analysis refuses above DENSE_LIMIT and says so, rather than quietly allocating gigabytes on a transmission grid; a sparse rank-revealing method is the natural follow-up.

Unknowns pinned this way are held at their starting value rather than estimated, and reported. That is deliberate: an unknown nothing observes cannot be recovered, and moving it would be inventing information. It also improves the failure mode — an under-measured system now solves its observable part and names the rest, instead of failing outright.

Bad data: whether the measurements agree

Two questions, answered separately.

Is there bad data at all?

Under the assumption that each error is independent and normal with the declared \(\sigma\), the objective is chi-squared distributed:

\[ J = r^{T} W r \sim \chi^2(m - n + k) \]

with \(m\) measurements, \(n\) estimated state variables and \(k\) equality constraints — each constraint gives a degree of freedom back, and a pinned unobservable variable never consumed one. A \(J\) far out in the tail says the residuals are too large to be noise.

Rejection says something is wrong, not which measurement. A test that does not reject is also not a clean bill of health: a single moderate error, or several that partly cancel, can sit inside the threshold.

Which measurement?

The largest normalized residual. Raw residuals are not comparable — 0.1 is enormous against a \(\sigma\) of 0.001 and negligible against a \(\sigma\) of 1 — and dividing by \(\sigma\) alone is still not enough, because a redundantly measured quantity spreads its error across its neighbours and so under-shows in its own residual. The right scale is the residual's own standard deviation:

\[ r_i^{N} = \frac{\vert r_i \vert}{\sqrt{\Omega_{ii}}}, \qquad \Omega = R - H G^{-1} H^{T} \]

conventionally compared against 3. \(\Omega\) is computed through the augmented system, so the zero-injection constraints count: a constrained estimate has less freedom to move, which changes how much of an error surfaces in the residual rather than in the state.

\(\Omega_{ii}\) costs one linear solve per measurement, so the full diagonal costs more than the estimate itself. gridoxide shortlists candidates by the cheap proxy \(\vert r_i \vert / \sigma_i\) and computes \(\Omega_{ii}\) exactly for the worst 20, configurable. This is an approximation with a real failure mode, not a free shortcut: because \(\Omega_{ii}\) varies per measurement, the true worst can in principle sit outside the shortlist. Raising the limit to \(m\) removes the doubt at the corresponding cost.

A measurement whose residual has no variance at all is critical in the usual terminology — the estimate is forced to reproduce it exactly, so an error in it cannot be detected by any amount of analysis. Those are skipped rather than assigned a meaningless normalized residual.

What the fixtures show

Run over power-grid-model's own state-estimation fixtures:

Fixture\(\chi^2\)dofRejected at 5%
1os2msr3.4e-1914no
1os2msr-no-angle9.6e-2212no
inf-measurement-with-injection1.1e-202no
transmission-case2.4e-748no
node-injection-sensor-and-zero-injection2.0e44yes

The last one's worst suspect is its injection sensor at a normalized residual of exactly 100.00 — the 100-sigma conflict that fixture is built around, recovered as a number.

One caveat when reading those figures: the consistent fixtures produce \(\chi^2 \approx 0\) rather than \(\chi^2 \approx \text{dof}\), because power-grid-model generated their readings from the true state without adding noise. There is nothing for a correct estimate to disagree with. Real telemetry would sit near its degrees of freedom, and a near-zero statistic on real data would itself be suspicious — it would suggest the declared sigmas are far too large.

A Weighted Least Squares Estimate, Worked

The problem page states the algebra; this one carries it out. It has three parts: a linear estimate small enough to do with a calculator, the measurement Jacobian written out for the nonlinear case, and what gridoxide reports on a real fixture.

The first part is pen-and-paper arithmetic, not gridoxide output — gridoxide has no DC estimator, and a DC estimate is the only version of this whose every intermediate fits on a page. It is exactly the algebra se::nr runs, with \(h\) linear so that \(H\) is constant and one step is exact.

Part 1: three buses, four measurements, one step

The network

Three buses in a triangle, every branch with \(x = 0.1\) p.u., so \(b = 10\) throughout. Bus 1 is the angle reference, so the unknowns are

\[ x = \begin{bmatrix} \theta_2 \\ \theta_3 \end{bmatrix}, \qquad n = 2 \]

In DC, a branch flow is \(P_{ij} = b_{ij}(\theta_i - \theta_j)\) and nothing depends on voltage magnitude at all.

The measurements

Three, so the redundancy is \(m - n = 1\).

\(i\)Quantity\(h_i(x)\)\(z_i\) (p.u.)\(\sigma_i\)
1flow 1→2\(10(\theta_1 - \theta_2) = -10,\theta_2\)0.200.01
2flow 1→3\(-10,\theta_3\)0.420.01
3flow 3→2\(10(\theta_3 - \theta_2)\)−0.180.02

The three disagree, which is the whole point. Flows 1 and 2 imply \(\theta_2 = -0.020\) and \(\theta_3 = -0.042\), and those two together imply a 3→2 flow of \(-0.22\) — not the \(-0.18\) the third meter reports. No state reproduces all three.

\(H\), \(W\) and \(G\)

\(h\) is linear, so \(H = \partial h/\partial x\) is constant:

\[ H = \begin{bmatrix} -10 & 0 \\ 0 & -10 \\ -10 & 10 \end{bmatrix}, \qquad W = \begin{bmatrix} 10^4 & & \\ & 10^4 & \\ & & 2.5 \times 10^3 \end{bmatrix} \]

Note the third meter's weight: \(1/0.02^2 = 2500\), a quarter of the others', because it is trusted half as far. Then

\[ G = H^{T} W H = \begin{bmatrix} 1.25 \times 10^6 & -2.5 \times 10^5 \\ -2.5 \times 10^5 & 1.25 \times 10^6 \end{bmatrix}, \qquad H^{T} W z = \begin{bmatrix} -15500 \\ -46500 \end{bmatrix} \]

Each entry is worth checking once by hand, because the pattern is the whole trick: \(G_{11} = 10^4(-10)^2 + 2500(-10)^2 = 1.25\times 10^6\) — meters 1 and 3 both see \(\theta_2\) — while \(G_{12} = 2500 \cdot (-10)(10) = -2.5 \times 10^5\) comes from meter 3 alone, the only one that sees both unknowns. \(G\)'s sparsity is the measurement graph, not the network graph.

The estimate

Solving \(G,\hat{x} = H^{T} W z\) — one step, because the problem is linear:

\[ \hat{\theta}_2 = -\frac{31}{1500} = -0.0206\overline{6}, \qquad \hat{\theta}_3 = -\frac{31}{750} = -0.0413\overline{3} \]

Neither matches what any pair of meters said on its own. The estimate splits the difference, weighted:

\[ h(\hat{x}) = \begin{bmatrix} 0.20\overline{6} \\ 0.41\overline{3} \\ -0.20\overline{6} \end{bmatrix}, \qquad r = z - h(\hat{x}) = \begin{bmatrix} -0.00\overline{6} \\ +0.00\overline{6} \\ +0.02\overline{6} \end{bmatrix} \]

The residual does not vanish, and it never will: the measurements contradict each other, so \(r \ne 0\) at the optimum. This is the single most important difference from a power-flow Newton loop, which drives its mismatch to zero because an exact solution exists. Convergence here is tested on the size of the step, never on the residual.

Note also where the residual landed: the third meter, the least trusted one, absorbs four times as much of the disagreement as either of the others. That is the weighting doing its job.

The objective and the chi-squared test

\[ J(\hat{x}) = r^{T} W r = 0.4\overline{4} + 0.4\overline{4} + 1.7\overline{7} = \frac{8}{3} = 2.667 \]

with \(m - n = 1\) degree of freedom. The 5% critical value of \(\chi^2(1)\) is 3.841, and \(2.667 < 3.841\), so the test does not reject. The three meters disagree about as much as meters of their declared accuracy would be expected to.

Normalized residuals, and why they are all equal

\[ \Omega = R - H G^{-1} H^{T}, \qquad r_i^{N} = \frac{\vert r_i \vert}{\sqrt{\Omega_{ii}}} \]

With

\[ G^{-1} = \begin{bmatrix} 8.\overline{3} \times 10^{-7} & 1.\overline{6} \times 10^{-7} \\ 1.\overline{6} \times 10^{-7} & 8.\overline{3} \times 10^{-7} \end{bmatrix} \]

the diagonal of \(\Omega\) comes out as

\(i\)\(R_{ii} = \sigma_i^2\)\((HG^{-1}H^{T})_{ii}\)\(\Omega_{ii}\)\(r_i^{N}\)
1\(10^{-4}\)\(8.\overline{3} \times 10^{-5}\)\(1.\overline{6} \times 10^{-5}\)1.633
2\(10^{-4}\)\(8.\overline{3} \times 10^{-5}\)\(1.\overline{6} \times 10^{-5}\)1.633
3\(4 \times 10^{-4}\)\(1.\overline{3} \times 10^{-4}\)\(2.\overline{6} \times 10^{-4}\)1.633

All three are identical, and that is not a coincidence of the numbers chosen. With one degree of freedom the residual vector is confined to a one-dimensional subspace, so every normalized residual is \(\pm\sqrt{J}\) — here \(\sqrt{8/3} = 1.633\) exactly.

The lesson is the one worth taking away from this whole page. At redundancy 1, bad data can be detected and never identified: whichever meter is broken, the analysis accuses all three equally. Identification needs redundancy 2 or more, and a measurement whose residual has no variance at all — \(\Omega_{ii} = 0\) — is critical: the estimate is forced to reproduce it exactly, so an error in it cannot be detected by any amount of analysis. Those are skipped rather than given a meaningless normalized residual.

Notice also that \(\Omega_{ii} < \sigma_i^2\) for every meter. That is why dividing by \(\sigma\) alone is not enough: a redundantly measured quantity spreads its error across its neighbours and so under-shows in its own raw residual.

Part 2: the nonlinear case, written out

In AC the flows are not linear in the state, \(H\) changes every iteration, and the estimate is Gauss-Newton:

\[ G(x^{(k)})\ \Delta x = H(x^{(k)})^{T} W, r(x^{(k)}), \qquad x^{(k+1)} = x^{(k)} + \Delta x \]

The state layout has no PV buses — a generator's voltage is estimated, not asserted — so every bus contributes a magnitude and every bus but the angle reference contributes an angle:

\[ x = \bigl[\ \theta_0 \ldots \theta_{N-1} \text{ except the reference},\ \ \vert V_0 \vert \ldots \vert V_{N-1} \vert\ \bigr], \qquad n = 2N - 1 \]

against power flow's \(n_{angle} + n_{pq}\). Confusing the two layouts is the easiest way to produce a Jacobian that is subtly and consistently wrong, so se::jacobian::StateLayout owns the mapping and nothing else indexes by hand.

One formula for every power measurement

A textbook writes a separate Jacobian block for a bus injection, a branch terminal flow, a shunt draw and so on. gridoxide writes one, because they are the same thing five ways: a current that is linear in the complex bus voltages, evaluated against the voltage at one bus.

\[ I = \sum_k c_k V_k, \qquad S = V_{at},\overline{I} \]

The pair \((c, at)\) is all that distinguishes them:

Measurement\(c\)\(at\)
Bus injection at \(i\)the Y-bus row \(i\)\(i\)
Branch terminal flowthat branch's own half-rowthe terminal's bus
Shunt drawa single negated admittancethe shunt's bus

and the derivatives follow once, for all of them:

\[ \frac{\partial S}{\partial \theta_k} = -j,V_{at},\overline{c_k V_k} ;+; [,k = at,]; j S, \qquad \frac{\partial S}{\partial \vert V_k \vert} = V_{at},\overline{c_k e^{j\theta_k}} ;+; [,k = at,]; \frac{S}{\vert V_{at} \vert} \]

with \(\operatorname{Re}\) giving the \(P\) row and \(\operatorname{Im}\) the \(Q\) row.

It reduces to the textbook expressions

Take the injection case — \(c = Y_{i\cdot}\), \(at = i\) — and a bus \(k \ne i\). Writing \(Y_{ik} = G_{ik} + jB_{ik}\) and \(\theta_{ik} = \theta_i - \theta_k\),

\[ V_i \overline{Y_{ik} V_k} = \vert V_i \vert \vert V_k \vert \bigl[,G_{ik}\cos\theta_{ik} + B_{ik}\sin\theta_{ik} ;+; j\bigl(G_{ik}\sin\theta_{ik} - B_{ik}\cos\theta_{ik}\bigr)\bigr] \]

and multiplying by \(-j\) swaps the parts, giving

\[ \frac{\partial P_i}{\partial \theta_k} = \vert V_i \vert \vert V_k \vert \bigl(G_{ik}\sin\theta_{ik} - B_{ik}\cos\theta_{ik}\bigr), \qquad \frac{\partial Q_i}{\partial \theta_k} = -\vert V_i \vert \vert V_k \vert \bigl(G_{ik}\cos\theta_{ik} + B_{ik}\sin\theta_{ik}\bigr) \]

which are exactly the off-diagonal power-flow Jacobian entries from the power flow page. The \([k = at]\) terms recover the diagonal ones. A bus-injection measurement has the same row a bus-injection mismatch does; what differs is the system that consumes it.

se::jacobian's finite-difference test is the real guard on all of this, and it is applied to the unified form on every measurement kind rather than to each closed form separately.

Structural zeros are kept

Every coefficient produces an entry in \(H\), including one whose numerical value is zero. \(H\)'s sparsity pattern has to depend on the topology alone and not on the state: dropping a zero would shrink the row at a flat start — where many \(\sin(\theta_i - \theta_k)\) vanish exactly — and grow it again next iteration, invalidating the cached symbolic factorization that makes the solver reuse worthwhile.

Part 3: what the code reports

power-grid-model's 1os2msr fixture: three nodes, two lines, one source, three voltage sensors with angles, and seven power sensors.

$ gridoxide estimate tests/data/pgm/state_estimation/1os2msr/input.json

4 bus(es), 20 measurement(s) after aggregation
Converged in 4 iteration(s)
Objective J(x) = 3.413330e-19

Estimated voltages:
  node 1: |V| = 1.023912 p.u., angle = -0.747976 deg
  node 2: |V| = 1.024067 p.u., angle = -1.010546 deg
  node 3: |V| = 1.023650 p.u., angle = -1.156362 deg

Observability: rank 8 of 8 unknown(s)
  fully observable

Bad data: chi-squared 6.8267e-19 on 14 dof, p = 1.0000e0
  not rejected at 5%

Four numbers there are worth reading carefully.

Four buses, not three. gridoxide models a source structurally, as a virtual slack bus feeding through an impedance branch, so a three-node document has four buses. power-grid-model has no such bus in its state space, which is why a network it considers fully observable can still leave gridoxide with surplus unknowns. See where the models differ.

Eight unknowns, not seven. \(n = 2N\) here rather than \(2N - 1\), because this fixture's voltage sensors carry angles. A network measured only in magnitudes and powers is invariant under a global phase shift and needs a reference pinned or \(G\) is singular however many measurements there are; a phasor measurement supplies an absolute angle, that invariance disappears, and pinning a reference on top of one would be a false constraint rotating the whole estimate away from the data. gridoxide pins a reference exactly when no angle is measured — compare 1os2msr-no-angle, the same network with 12 degrees of freedom instead of 14.

Twenty measurements from ten sensors. A Measurement is a single scalar, not a sensor: Newton-Raphson WLS treats \(\sigma_P\) and \(\sigma_Q\) as independent, so each of the seven power sensors becomes two rows and each of the three angle-carrying voltage sensors becomes two.

\(J = 3.4 \times 10^{-19}\), not \(\approx m - n\). On real telemetry the objective would sit near its degrees of freedom. It sits at zero here because power-grid-model generated these readings from the true state without adding noise — there is nothing for a correct estimate to disagree with. A near-zero statistic on real data would itself be suspicious: it would suggest the declared sigmas are far too large.

14 degrees of freedom from 20 measurements and 8 unknowns. The count is \(m - n + k\): a variable that was pinned rather than estimated never consumed a degree of freedom, and each equality constraint gives one back. \(20 - 8 + k = 14\) says two zero-injection constraints were in force — enforced as hard constraints rather than as very-high-weight pseudo-measurements, which is what keeps \(W\) from spanning more orders of magnitude than the physics requires.

Run the same thing on the fixture built to fail and the machinery from Part 1 comes back with something to say:

Fixture\(\chi^2\)dofRejected at 5%
1os2msr3.4e-1914no
transmission-case2.4e-748no
node-injection-sensor-and-zero-injection2.0e44yes

The last one's worst suspect is its injection sensor at a normalized residual of exactly 100.00 — the 100-sigma conflict that fixture is built around, recovered as a number, by exactly the \(\Omega\) calculation done by hand above.

The Remedial Action Problem

Motivation

Power flow says what the network is doing. A contingency analysis says what it would do if something tripped. Neither says what to do about it, and by itself neither is actionable: an operator handed a list of post-contingency overloads still has to decide which switch to open, which phase shifter to move, and how far.

Remedial action optimization — RAO — is that decision, posed as an optimization. Given

  • a network,
  • a list of contingencies to survive,
  • the elements whose loading matters and the limits they must respect,
  • the actions an operator is permitted to take, and when,

it returns a plan: which actions to take before the fact, and which to hold in reserve for each contingency. It is the calculation behind European capacity calculation and coordinated security analysis, and the reference implementation is powsybl-open-rao, whose algorithm — CASTOR — this module follows.

The vocabulary, because the acronyms are load-bearing

TermWhat it is
CRACContingencies, Remedial Actions and Additional Constraints — the document defining the problem. Everything below comes out of it.
ContingencyThe simultaneous loss of one or more elements. More than one element makes it N-k.
InstantA moment in the chronology after a contingency: preventive, outage, auto, curative.
StateA (contingency, instant) pair. The preventive state has no contingency.
CNECCritical Network Element and Contingency — one monitored element in one state. The same line yields a different CNEC per state, with different limits.
ThresholdA limit on a CNEC's flow, in MW, amperes, or a fraction of \(i_{max}\).
Range actionA control with a continuum of settings: a phase shifter's angle, a redispatch in MW.
Network actionA discrete control: open a switch, change a topology.
PerimeterA set of states optimized together, sharing one set of decisions.

The instant chronology is what makes this more than one optimization:

\[ \text{preventive} ;\longrightarrow; \text{outage} ;\longrightarrow; \text{auto} ;\longrightarrow; \text{curative} \]

An outage instant is too soon for anyone to act, so whatever protects it must already have been done preventively. An auto instant is what automatic devices do, which is forced and simulated rather than chosen — a protection scheme fires whether or not it helps. Only preventive and curative instants carry decisions.

The problem

Write \(x\) for the vector of discrete decisions (which network actions are taken) and \(a\) for the vector of continuous set-points (range actions). For a state \(s\), each CNEC \(c\) carries a flow \(F_s(c;, x, a)\) and a pair of limits \(f^{-}(c) \le F \le f^{+}(c)\).

The margin of a CNEC is its distance to whichever limit is nearer:

\[ m(c) ;=; \min\bigl(, f^{+}(c) - F(c),;; F(c) - f^{-}(c) ,\bigr) \]

and the objective is to make the worst one as large as possible:

\[ \max_{x,,a}\ \ \min_{s}\ \min_{c \in s}\ m_s(c) \]

subject to the range actions' own bounds, the power flow, and whatever usage rules the CRAC places on each action.

Three properties of that statement drive everything in this module:

It is a max-min, not a sum. The objective is the worst margin, so improving an already-healthy CNEC is worth nothing. This is what makes it expressible as a linear program — see the linear optimization — and it is also why a plan can look like it "did nothing" while being optimal.

Negative margins are the normal case. A margin is negative exactly when a limit is exceeded, so the objective is meaningful on an insecure network and the sign of the answer is the security verdict. gridoxide security exits 1 when any margin is negative, so it can gate a pipeline.

\(x\) is discrete and \(a\) is continuous. There is no gradient between an open switch and a closed one, so the two halves need different machinery: a search tree over \(x\), and a linear program over \(a\) at every node of it. They interleave rather than run in sequence, because a topological change moves the sensitivities the phase shifters are optimized against.

The three layers

LayerAnswersCodePage
Evaluationwhere does it hurt, and by how muchrao::evaluateMargins
Linear optimizationwhat should the continuous controls berao::linearThe LP
Search and decompositionwhich discrete actions, in which perimeterrao::search, rao::castorThe search tree

Evaluation is a deliverable on its own. gridoxide could run a contingency analysis before any of this existed, but it could not say whether the result was acceptable, because nothing told it what the limits were or which elements anyone cared about.

Why the flows are DC

Every margin, sensitivity and candidate score in the search is computed on the DC linearization, and the whole post-contingency sweep comes out of one factorization: DcSensitivity::multi_outage_flows answers an N-k outage with a Woodbury update rather than a re-solve. On case9241pegase that is 0.40 ms against 13.4 ms.

A search tree evaluates thousands of candidates, so that ratio is the difference between a tool that runs in a control room and one that does not. An AC gate is available (FlowModel::Ac, gridoxide rao --validate-ac) for scoring and for the final verdict, at the cost of a Newton-Raphson solve per outer iteration.

The worked example used throughout

The next three pages share one network, OpenRAO's 3nodes_pst.uct — five buses, three countries, six branches, one phase shifter — small enough that every number on them can be checked with a calculator. It is read from the benchmark-grids submodule, at tests/data/benchmark-grids/powsybl-open-rao/files/cases/flowbased_computation/3nodes_pst.uct.

                 ┌─── FFR1AA1 ───┐
                 │   (+500 MW)    │
        2 lines  │                │ 1 line
                 │                │
            BBE1AA1 ──[PST]── BBE2AA1
            (+500 MW)              │
                                   │ 1 line
                                   │
                              DDE1AA1 ──── DDE2AA1
                             (−1000 MW)     (radial)

Every line has \(x = 10\ \Omega\). The nominal voltage is 380 kV — UCTE encodes it in the seventh character of the node code, where 1 means 380 kV, and the 400.00 in the node record is a regulation reference rather than the base. With \(S_{base} = 100\) MVA the impedance base is \(380^2/100 = 1444\ \Omega\), so

\[ x_{pu} = \frac{10}{1444} = 0.006925, \qquad b = \frac{1}{x_{pu}} = 144.4 \text{ p.u.} \]

for every line — which is exactly what dc_branches reports. The phase shifter's own reactance is zero in the file and is clamped to the zero-impedance threshold, making it an ideal shifter to within 0.02%.

Running it

Assess first, optimize second. The two are separate commands because the first answers a question worth asking on its own:

# Where does it hurt?
gridoxide security tests/data/benchmark-grids/powsybl-open-rao/files/cases/flowbased_computation/3nodes_pst.uct \
    --crac docs/examples/pst-worked-example.crac.json

# What should we do about it?
gridoxide rao tests/data/benchmark-grids/powsybl-open-rao/files/cases/flowbased_computation/3nodes_pst.uct \
    --crac docs/examples/pst-worked-example.crac.json --depth 2

# ... under the reference's own settings, rather than gridoxide's defaults.
gridoxide rao tests/data/ucte/3nodes_pst.uct \
    --crac docs/examples/pst-worked-example.crac.json \
    --parameters RaoParameters.json

--parameters reads OpenRAO's own RaoParameters document: the objective (including MIN_COST), the flow model, MNEC handling, the second-preventive execution condition, the curative stop criterion and the search thresholds. Without it a run is max-min-margin on DC with no MNECs and no second pass, which is a fraction of what the optimizer does — and until --parameters existed that was the only thing the binary could be asked for, so the gate validated behaviour no user could reach. Settings the file states and gridoxide does not read are ignored rather than refused, and src/rao/parameters.rs lists which and why.

Both take a UCTE .uct, an IIDM .xiidm, or a CGMES model — for CGMES, the directory holding the profile set or any one profile beside the others:

gridoxide security path/to/SmallGrid-Merged --crac smallgrid.crac.json

A CRAC names a CGMES element by the mRID of its ConductingEquipment, which is what powsybl gives an IIDM element converted from CGMES. Both need --features rao plus an importer (ucte, iidm or cgmes). The CRAC may be OpenRAO JSON — the format has 24 versions across the reference checkout's 428 files and the reader spans them — or gridoxide's own <network>.rao.json companion.

What is validated

197 scenarios from powsybl-open-rao's own Cucumber suite read from the benchmark-grids submodule and selected by the lists under tests/data/rao/, are the gate here. They are the only check in this repository that gridoxide did not write for itself: they state margins to the decimal and name which remedial actions should be used, and their authors wrote them to judge a different implementation.

1830 of 1862 checkable assertions match, at the reference's own tolerance of max(5, 1.5%) in whichever unit the step is written — margins, flows per side, taps, thresholds, named actions, action counts, set-points, objective values, security statuses and which optimization steps ran, across two flow models and three networks. Nothing is skipped: every step the corpus states is checked, so the ratio is the whole of it rather than the part that was convenient.

file
dc_scenarios.txt180 of 186
ac_scenarios.txt276 of 282TestCase12Nodes
ac_scenarios_16nodes.txt963 of 977TestCase16Nodes
second_preventive.txt123 of 123
min_cost.txt288 of 294costly optimization

Two of the five were vendored before the capability they test existed, which is the order the rest of this was built in and the only one that works. The second-preventive corpus scored 45 of 108 with nothing implemented; the costly one scored 165. In both cases the gate then found every defect one scenario at a time, and a corpus vendored after the fact only ever confirms what its author already believed.

All 32 assertions that do not match are recorded disagreements rather than a backlog, in ten scenarios. There is no open defect left in the corpus. Each has been measured on the reference's own objective, in the unit its own configuration selects and with its own MNEC violation cost applied, and in none of them is gridoxide worse: some are its BestTapFinder rounding a set-point on minimum margin alone, blind to a virtual cost its own javadoc warns about; two are gridoxide securing the network more cheaply than the reference asks under MIN_COST, which is the objective working rather than a defect; the rest are ties reached by a different route. The gate names each with the measurement behind it and refuses to let a scenario disagree without one — see RECORDED_DISAGREEMENTS in tests/rao_cucumber_test.rs.

The last open defect — 1.4.1.5 and 1.4.1.6, a curative perimeter spending three actions where the reference spends one — is closed. Seven mechanisms had been implemented, measured and refuted for it; the eighth was read out of the reference's own source rather than guessed, which took a morning where the seven had taken weeks. second_preventive.txt went 118 → 123.

What is not here

  • Loop flows and relative margins.

  • HVDC and counter-trade range actions, recognised and skipped. gridoxide models a DC network but nothing connects it to a range action yet; a counter trade has no network sensitivity at all, which is why the reference leaves it out of its own LP too. Neither appears in any CRAC the vendored corpus loads, so building either would be building blind.

  • Angle and voltage CNECs, counted rather than modelled — the same boundary the reference draws, which checks them in a separate monitoring pass after the fact. gridoxide has copied the exclusion from the LP and not that monitoring pass, so a CRAC leaning on them is not fully answered. One consequence is worth stating because nothing else does: a usage rule conditioned on an angle or voltage constraint names a CNEC that is never evaluated, so it can never fire. That is conservative — the action is not offered rather than offered freely — but it is a silence, not a decision the optimizer makes.

  • An integer tap. pst-model: APPROXIMATED_INTEGERS is read by nothing and TapModel::Discrete is declared and unbuilt — measured rather than assumed. Of 37 vendored configurations 5 ask for it, governing 24 scenarios, and the reference built eight of them as controlled pairs ("copy of 2.6.2.x with MIP for PSTs"). All 24 match with the continuous model and rounding, so building the integer tap could win nothing here and could only lose.

  • Multi-timestamp (MARMOT) runs.

  • A relativeToPreviousInstant curative range inside the second preventive problem. The rest of that problem is here. After the curative stage, the preventive perimeter is optimized again with every CNEC in front of it and the automaton and curative switching held applied, and the result is kept only if the whole plan it leads to is better. The curative range actions get a column of their own in it — A(r, s), a set-point per action per state — so the pass can see a curative shifter paying for a preventive push; the column is scoped to the states it governs and is not reported, because the curative perimeter that follows decides it properly.

    A range written relative to the previous instant is expressed too, and the way it had to be is the interesting part: its window is relative to another column — the preventive set-point the same problem is deciding — so it is stated as a row, min ≤ A(r,s) − A(r) ≤ max, rather than as the column's own bounds. An earlier version declined such a column on the grounds that the LP carries one bound pair per column and no row coupling two of them, which was true of a bound and not of a row. It changes no answer in the vendored corpus; what it removes is a case where the second pass could not see a curative shifter at all.

    What the reference additionally does is keep the curative decisions afterwards rather than re-deriving them. That is a separate change, and measured on its own it cost 2 and fixed nothing.

    What that pass holds is exact about two things it used to get wrong, and both were worth more than any of the architecture around them. A curative set-point is deliberately not held: the pass optimizes one network standing in for every state, so anything held in it is held in the preventive and outage states too, where a curative decision is not in force. For a switch that is the price of letting the pass see the curative CNECs at all. For a set-point it is a stale iterate — chosen against preventive decisions the second pass exists to discard, and recomputed the moment it returns — and holding one lets the two passes settle into a fixed point neither can leave. A curative close is held, which took noticing: the held set is built by adding the branches a curative perimeter leaves open, and a close is invisible to an add-only rule, so the second pass optimized against an overload the curative stage had already removed.

    And the switching is held only in the states that see it. This is the one perimeter that spans every state at once, so one network for all of them puts a curative branch into the preventive and outage states, where no curative decision has been taken, and reads their CNECs somewhere they do not live. evaluate::Held carries the difference as a delta and the objective, the base flows and the sensitivities all read a CNEC in its own state's network — which is why an outage CNEC and a curative one under the same contingency no longer share a linearization.

  • Several perimeters' set-points as variables in one LP. Each perimeter is solved against the previous one's fixed decisions, which is the CASTOR decomposition rather than a shortcut past it. The relativeToPreviousInstant range kind is honoured — what chains across perimeters is the bound, not the variable. See the linear chapter.

The AC evaluation distributes the slack, weighted by generation, because every configuration the reference ships does. All three importers retain the per-bus generation it needs. It is not a refinement: when a contingency islands a generator the imbalance is that machine's whole output, and where it reappears decides the flows. On one vendored fixture a single slack sits at the end of a tie into the country that just lost 1000 MW, so it pushes the entire make-up back through that country and over the line being measured — 1165 MW against a true 1000. Weighting by net injection rather than generation puts 70% of it back in the same place and barely helps, which is why the setting looked innocent for a long time.

Angle and voltage CNECs are counted rather than modelled, which is what the reference does too: it checks them in a separate monitoring pass after the fact rather than putting them in its LP.

CNECs, Thresholds and Margins

Everything the optimizer maximizes is defined on this page, and none of it is difficult. It is nevertheless where a RAO is most likely to be quietly wrong, because a threshold arrives in one of four units, may be one-sided, may be stated per terminal, and is tightened by a reliability margin before anyone sees it. Get any of that wrong and every subsequent number stays perfectly self-consistent while describing a limit nobody wrote.

From a threshold to a pair of bounds

A CNEC carries a list of thresholds. Each states a min, a max, or both, in one of

UnitMeaningConversion to MW
megawattactive power on the branchnone
amperecurrent\(P = \sqrt{3}, U, I\)
percentImaxa fraction of the CNEC's own \(i_{max}\)\(I = \phi \cdot i_{max}\), then as above
degree, kilovoltangle and voltage CNECsnot expressible on a flow

The ampere conversion is the one worth writing out, because dropping the \(\sqrt{3}\) is a 73% error that still looks entirely plausible:

\[ P_{pu} ;=; \frac{\sqrt{3}; U_{rated}; I}{S_{base}}, \qquad\qquad I ;=; \frac{P_{MW}\cdot 10^{6}}{\sqrt{3}; U} \]

with \(U\) line-to-line, in volts. ratings::current_to_power_pu exists as a named function purely so that \(\sqrt{3}\) has one home.

percentImax is a fraction and not a percentage, despite the name — 1.0 means 100%, as the reference's own ThresholdAdder javadoc states. Reading it as a percentage makes every such threshold a hundred times too tight.

Thresholds accumulate, they do not replace

A CNEC with several thresholds is bounded by the tightest of each side, taken independently:

\[ f^{+} ;=; \min_t\ \bigl(\text{max}_t\bigr) - \text{FRM} - \text{charge}, \qquad f^{-} ;=; \max_t\ \bigl(\text{min}_t\bigr) + \text{FRM} + \text{charge} \]

Each side remembers the voltage its own binding threshold named, so an ampere margin can be converted back at the right voltage even when a CNEC's thresholds disagree about which that is.

FRM is the CRAC's reliability margin (frm), a fixed MW cushion held back from every limit to cover the difference between the model and the network. charge is the headroom already consumed by reactive flow, and is zero under DC — see the AC case below.

The bounds are not assumed symmetric

A CRAC routinely writes a one-sided threshold. min: -1500, max: null means no more than 1500 A in the reverse direction, and nothing at all in the forward one. Collapsing that to \(\vert F \vert \le 1500\) invents a constraint nobody wrote, in the direction the flow is most likely to go.

So Bounds starts at \((-\infty, +\infty)\) and only ever narrows, and a side that no threshold mentions stays infinite and contributes no row to the LP. A CNEC whose thresholds are all inexpressible — a percentImax threshold on a CNEC whose \(i_{max}\) the CRAC never stated, say — constrains nothing at all, which is better than constraining it with a number nobody wrote.

The margin

\[ m(c) ;=; \min\bigl(, f^{+} - F,;; F - f^{-} ,\bigr) \]

against the signed flow \(F\), not its magnitude. For a symmetric pair of bounds this reduces to the familiar

\[ m(c) ;=; \text{limit} - \vert F \vert \]

and for a one-sided pair that identity does not hold. The margin is the quantity to trust; CnecResult keeps limit_mw alongside it for display only, and the two disagree by construction on a one-sided CNEC.

The sign carries the verdict: \(m < 0\) is an overload, exactly, and by exactly the amount reported.

Two ends of one flow

CnecResult reports flow_mw at side one and side_two — active power and current — at the other, and the pair follows one rule: both are measured in the same direction along the branch. Power entering at side one, power leaving at side two. So the two differ by the branch's own losses, which is the comparison anyone asking for both ends wants, and under DC they are equal to the last bit because a linear model has no losses to spend.

The alternative reading — report the power entering at each end, which is what powsybl's Terminal::getP gives and what the AC evaluator computes internally — negates side two, and the two figures then differ by roughly twice the flow rather than by a fraction of a megawatt. Both readings agree on magnitude, so nothing but a per-side expectation from outside can tell them apart.

One consequence worth stating because it looks like an inconsistency: current_a and side_two.1 are magnitudes, since a current is one and a threshold is compared against one. A flow reported in amperes is signed, by the active power at that terminal — the reference divides the signed megawatts by (\sqrt{3},U) — so -1444.0 A names a direction rather than a smaller number. The evaluator keeps the magnitude and the caller applies the sign, which is why the two live apart.

One margin, in two units

CnecResult reports margin_mw and margin_a, and the second is not a unit conversion of the first at some convenient voltage — it is the same margin expressed in the units the binding threshold was written in, converted at the voltage that threshold named. Two CNECs at different voltages convert differently, which is precisely why the two units can rank the same pair of candidate networks in opposite orders:

A 400 kV CNEC and a 225 kV one with equal MW margins do not have equal ampere margins.

That is why the objective's unit is a setting at all — see the LP's objective unit.

Concretely, the ampere margin is

\[ m_A(c) ;=; f^{+}_A ;-; I \]

— the limit expressed in amperes less the current — and not \(m_{MW}\) divided by \(\sqrt{3},U\). For a threshold already written in amperes the two are the same number, because the charge above has taken reactive flow and the voltage deviation off the megawatt limit and converting back undoes exactly that. For a threshold written in megawatts they are not: \(\text{limit} - \vert P \vert) carries neither effect and \(I\) carries both.

The voltage that turns a megawatt limit into amperes is the network's nominal, not the CRAC's nominalV. A CRAC states nominalV to say what an ampere threshold was written against; a megawatt threshold was written against nothing, and the reference reads the CNEC's nominal voltage off the network. On a UCTE 380 kV line whose CRAC says 400 that is 150 A on a 2000 MW limit — enough to change which remedial action the search takes when the objective is in amperes.

A worked margin

Take the network from the chapter introduction and put a single CNEC on the BE2–DE1 tie, with a two-sided megawatt threshold of ±700 MW, no reliability margin, and no contingency:

"thresholds" : [ { "unit" : "megawatt", "max" : 700.0, "min" : -700.0, "side" : 1 } ]

The flow, by hand

The phase shifter's reactance is clamped to nearly zero, so BE1 and BE2 are one electrical node separated only by the shift \(\alpha\). Set that aside for a moment — take \(\alpha = 0\) — and the network reduces to three nodes B, D, F with

\[ b_{BD} = 144.4, \qquad b_{BF} = 2 \times 144.4 = 288.8, \qquad b_{DF} = 144.4 \]

and injections \(P_B = +5\), \(P_D = -10\), \(P_F = +5\) p.u. The radial DE1–DE2 branch carries nothing and can be dropped. Taking \(\theta_B = 0\), the two remaining DC equations are

\[ \begin{align*} 288.8,\theta_D - 144.4,\theta_F &= -10 \\ -144.4,\theta_D + 433.2,\theta_F &= +5 \end{align*} \]

Since \(144.4/433.2 = 1/3\) exactly, eliminating \(\theta_F\) gives \(240.667,\theta_D = -8.3333\), so

\[ \theta_D = -0.034626\ \text{rad}, \qquad \theta_F = \frac{5 + 144.4,\theta_D}{433.2} = 0 \]

\(\theta_F = 0\) exactly: by symmetry France sits at Belgium's angle, its 500 MW flows straight to Germany over the DE1–FR1 line, and Belgium's 500 MW flows over BE2–DE1. So with the shifter at neutral,

\[ F_{BD} = 144.4 \times (0 - (-0.034626)) = 5.000\ \text{p.u.} = 500\ \text{MW} \]

What the shifter adds

The file leaves the PST at tap −8, which the UCTE ##R record turns into a shift of \(\alpha = 0.054387\) rad \(= 3.116°\) in gridoxide's internal convention. (A CRAC states the same angle as −3.116°, since IIDM's sign is the opposite; the conversion happens once, on the way in, and the next page works in the CRAC's frame.) A shift drives a circulating flow around the loop BE1–BE2–DE1–FR1–BE1, whose series reactance is

\[ x_{loop} = \frac{1}{144.4} + \frac{1}{144.4} + \frac{1}{288.8} = 0.017313\ \text{p.u.} \]

so the loop susceptance is \(b_{loop} = 57.76\) p.u. and the circulating flow is \(b_{loop},\alpha\):

\[ \frac{\partial F_{BD}}{\partial \alpha} = 57.76 \times \frac{\pi}{180} = 1.0081\ \text{p.u./deg} = 100.81\ \text{MW/deg} \]

against the 100.7896 MW/deg the code computes — the 0.02% gap is the phase shifter's clamped, not quite zero, reactance. So

\[ F_{BD} = 500 + 100.79 \times 3.116 = 814.0\ \text{MW} \]

The margin

\[ m = \min(700 - 814.0,\ \ 814.0 + 700) = \min(-114.0,\ 1514.0) = -114.0\ \text{MW} \]

which is what the evaluator reports:

$ gridoxide security tests/data/benchmark-grids/powsybl-open-rao/files/cases/flowbased_computation/3nodes_pst.uct \
      --crac docs/examples/pst-worked-example.crac.json

preventive / base case: 1 monitored, worst margin -114.0 MW
  OVERLOAD BE2-DE1 - preventive         flow     814.0  limit     700.0  by    114.0 MW

INSECURE: 1 overload(s) across 1 perimeter(s)

Note which bound binds: the upper one. Had the threshold been one-sided in the other direction, the margin would have been +1514 MW and this network perfectly secure — the same flow, the same element, a different question.

The AC case

Under FlowModel::Ac two things change that the linear model cannot see, and both act on an ampere threshold only — an MW threshold binds active power, which is exactly what the evaluator reports either way.

Current is measured at the voltage the bus is actually running at. A bus at 1.05 p.u. carries a given MW at 5% less current than nominal, so a DC study reading its ampere thresholds at nominal voltage is conservative there and optimistic wherever voltage has sagged.

Reactive flow consumes thermal headroom. An ampere threshold binds \(\vert S \vert\), not \(\vert P \vert\). Rather than change what a margin means, the reactive part is charged against the limit:

\[ \text{charge} ;=; \max\left(0,\ \ \sqrt{P^2 + Q^2}\ \frac{U_{written}}{U_{actual}} ;-; \vert P \vert \right) \]

taken off both bounds, since the headroom is unavailable in either direction. The two effects arrive in one expression because they are the same conversion: the flow is re-expressed as the active power that would draw the same current at the voltage the threshold was written against.

The point of doing it this way is that \(m = \text{limit} - \vert P \vert\) still holds and an MW margin remains an MW margin. The alternative — reporting a margin on \(\vert S \vert\) — would mean two runs of the same study report margins that are not comparable depending on the flow model.

Resolution: the failure that produces no error

Before any of this, the CRAC's network-element ids have to be matched to gridoxide's branch and bus indices, and that step is fallible in an interesting way. An element that fails to resolve is not a small problem:

  • a CNEC that silently disappears is a constraint the optimizer will never see;
  • a contingency that fails to resolve is an outage it will never simulate;
  • a range action whose generators fail to resolve is a control dropped from the optimization while every margin still looks right.

All three produce a confident answer to a different question, so Resolution reports every unresolved id rather than treating a miss as an error or as nothing. Unresolved ids are legitimate — a CRAC written for a merged model names elements outside any single file — which is exactly why they have to be reported instead of either failing or being ignored.

Two matching subtleties, both of which look like sloppiness and are not:

  • Ids are matched exactly, then with surrounding whitespace trimmed. UCTE element ids are fixed-width and carry padding ("BBE1AA1 BBE2AA1 1"), and a CRAC written against the same network may or may not preserve it.
  • Bus ids additionally match with a trailing _generator or _load removed, because powsybl names a UCTE node's generator <node>_generator and a CRAC uses the name powsybl gave it. CNECs name branches and redispatches name buses; those are different id spaces, and resolving one against the other silently yields nothing.

The Linear Optimization of Range Actions

A range action has a degree of freedom — an angle, a number of megawatts — so it can be optimized rather than searched. This page is the linear program that does it: what the variables are, what each row means, why the whole thing is wrapped in an outer loop, and a worked example small enough to solve on paper.

The keystone: one linearization

Everything rests on writing each CNEC's flow as an affine function of the set-points:

\[ F(c) ;=; f_n(c) ;+; \sum_r \sigma_n(r, c),\bigl[A(r) - \alpha_n(r)\bigr] \]

with

  • \(A(r)\) the set-point of range action \(r\) — degrees for a phase shifter, MW for a redispatch;
  • \(\alpha_n(r)\) where that action sits at the start of outer iteration \(n\);
  • \(f_n(c)\) the flow at that operating point;
  • \(\sigma_n(r, c)\) the sensitivity \(\partial F(c) / \partial A(r)\).

Both \(f_n\) and \(\sigma_n\) are recomputed each outer iteration. The subscript is the whole reason there is an outer iteration — see below.

Where the sensitivities come from

For a redispatch the sensitivity is a PTDF difference: shifting one MW from bus \(j\) to bus \(k\) changes branch \(c\)'s flow by \(\text{PTDF}{c,j} - \text{PTDF}{c,k}\), scaled by the action's distribution keys. DC flow is linear in injection, so this is exact.

For a phase shifter the derivative has two terms, and omitting either is a silent error rather than a loud one:

\[ \frac{\partial F_b}{\partial \alpha_s} ;=; \underbrace{b_s \bigl[\text{PTDF}{b,,f(s)} - \text{PTDF}{b,,t(s)}\bigr]}{\text{indirect, through the angles}} ;;-;; \underbrace{b_s ,\bigl[, b = s ,\bigr]}{\text{direct, on the shifting branch itself}} \]

for a shifter on branch \(s\) with susceptance \(b_s\) between buses \(f(s)\) and \(t(s)\).

The indirect term exists because btheta puts a shift on the right-hand side as an injection of \(+b_s\alpha\) at from and \(-b_s\alpha\) at to, so the response is exactly what those two injections produce. The direct term exists because the shifting branch's own flow is \(b_s(\theta_f - \theta_t - \alpha)\), in which \(\alpha\) appears explicitly, contributing \(-b_s\) to its own sensitivity and to no other branch's.

Drop the direct term and you get a sensitivity that is correct everywhere except on the one branch the operator is actually moving — which is exactly the branch most likely to be the CNEC.

tests/rao_linear_test.rs checks the analytic derivative against a finite difference on every branch, which is the load-bearing test of this layer: everything else here is bookkeeping around one quantity, and if that quantity is wrong the optimizer confidently moves shifters the wrong way.

A useful sanity check falls out of the same test: a radial branch never responds to any shift. It carries whatever its bus demands regardless of any shifter, because there is no parallel path for a circulating flow to take.

The program

Per perimeter, with \(n\) range actions, the problem has \(3n + 1\) columns:

ColumnVariableBoundsObjective coefficient
\(3k\)\(A(r_k)\), the set-pointthe action's own range0
\(3k+1\)\(\Delta^{+}(r_k)\), upward movement\([0, \infty)\)\(+\rho_k\)
\(3k+2\)\(\Delta^{-}(r_k)\), downward movement\([0, \infty)\)\(+\rho_k\)
\(3n\)\(MM\), the minimum margin\((-\infty, +\infty)\)\(-1\)

and these rows:

One movement row per action, tying the set-point to its two non-negative movements:

\[ A(r_k) ;-; \Delta^{+}(r_k) ;+; \Delta^{-}(r_k) ;=; \alpha_n(r_k) \]

Splitting the movement in two is what lets \(\vert A - \alpha \vert\) be penalized linearly. At the optimum at most one of the pair is nonzero, because both carry a positive cost.

One balance row, if any redispatch action's distribution keys do not sum to zero:

\[ \sum_r \bigl(\Delta^{+}(r) - \Delta^{-}(r)\bigr) \cdot \sum_d \text{key}_d(r) ;=; 0 \]

An action whose keys sum to zero moves power between buses and is unaffected by this row. One whose keys do not sum to zero creates or destroys power, and this row is what stops it being used alone — while still allowing it alongside another that cancels it, which is a case a per-action check would forbid and a real CRAC contains. Without this row the optimizer happily invents generation and reports a margin no network could achieve.

Two margin rows per optimized CNEC, one per finite bound:

\[ MM ;\le; \bigl(f^{+}(c) - F(c)\bigr),k_c, \qquad\qquad MM ;\le; \bigl(F(c) - f^{-}(c)\bigr),k_c \]

Substituting the linearization makes each of these an ordinary linear row in \(A\) and \(MM\). \(F(c)\) never gets a column of its own: it appears only in these two rows, so eliminating it halves the problem for no loss.

The objective is

\[ \min\ \ -MM ;+; \sum_r \rho_r \bigl(\Delta^{+}(r) + \Delta^{-}(r)\bigr) \]

The penalties \(\rho_r\) are small — 0.01 per degree of phase shift, 0.001 per MW of redispatch — so they only break ties. But they are not zero, or the optimizer will happily move every shifter by a rounding error's worth for no gain, and the plan becomes unreadable.

The bounds are separate, not one magnitude used twice

A CNEC with only a lower threshold gets only the second row. Adding the first — which is what a symmetric \(\vert F \vert \le \text{limit}\) does — constrains the flow in a direction the CRAC left free, so the optimizer refuses set-points that are perfectly legal. An infinite bound contributes no row at all.

The objective unit is not cosmetic

\(k_c\) above is the scale factor that expresses this CNEC's margin in the objective's unit: 1 for megawatts, and this CNEC's own amperes-per-MW for amperes. Scaling the row is what makes the unit mean anything, and two CNECs at different voltages scale differently — which is why the two units can rank the same pair of candidate networks in opposite orders.

The reference does not make this a setting. RaoUtil.getFlowUnit returns megawatts for a DC load flow and amperes for an AC one, so the objective follows the flow model, and every threshold stated "in the objective's unit" — the minimum-impact thresholds among them — follows with it.

One definition of the margin, not two

The limits this program optimizes against come from evaluate, not from re-reading the CRAC's thresholds here. That is not tidiness. A CRAC states thresholds in MW, in amperes, or as a fraction of a rated current, and each needs a different conversion; doing that conversion twice invites the optimizer to maximize a quantity nobody measures. It did, briefly — the LP read an ampere threshold as MW and so optimized against a limit some 40% adrift of the real one, while the evaluator scored it correctly. Every margin stayed self-consistent and the answer was simply wrong.

Monitored elements, the other kind of constraint

A CRAC labels each CNEC with two independent flags. optimized puts it in the objective: make this better. monitored says something else entirely: whatever you do elsewhere, do not ruin this. A CNEC can carry either, both, or neither, and the second is an MNEC.

The two cannot both be maximized. Unloading one branch loads another, so a program told to maximize every margin at once would have no answer. The reference makes the second a penalized soft constraint: an MNEC may be pushed past its limit, and the objective pays for every megawatt (or ampere) of it.

"Not worse" is not "not negative"

The rule is

\[ v(c) ;=; \max\bigl(0,; \min(0,; m_0(c) - d) ;-; m(c)\bigr) \]

with \(m_0\) the margin the untouched network had, \(m\) the margin now, and \(d\) the acceptable decrease — 50 by default, in the objective's unit.

The inner \(\min(0, \cdot)\) is the part to read twice. The floor an MNEC is held to is zero, or its own initial margin less \(d\), whichever is lower:

where it startedfloorreading
\(m_0 \ge d\)\(0\)all of its margin is available, not just \(d\) of it
\(0 \le m_0 < d\)\(m_0 - d\)it may be pushed through its threshold, by the part of \(d\) it had not used
\(m_0 < 0\)\(m_0 - d\)already overloaded: \(d\) worse and no worse, and no obligation to repair it

Reading it as a flat "no more than \(d\) worse" gets the first row wrong by the entire initial margin. Reading it as "never negative" gets the other two wrong by \(d\). The reference wrote one scenario per row — 5.2.1.2, 5.2.1.4 and 5.2.1.3 — and all three are in the vendored gate.

In the program

Each monitored CNEC earns a column \(V(c) \ge 0\) and, for each bound the CRAC states, one row:

\[ F(c) - V(c) ;\le; \max\bigl(f^{+}(c),; f_0(c) + d\bigr) - a \qquad F(c) + V(c) ;\ge; \min\bigl(f^{-}(c),; f_0(c) - d\bigr) + a \]

with \(f_0\) the initial flow and \(a\) the constraint-adjustment coefficient, zero by default. The objective gains \(\sum_c \pi, V(c)\) at the configured violation cost \(\pi\), 10 by default. An MNEC gets no \(MM\) row: it is not something to improve.

The same penalty enters the objective the search tree ranks leaves by, and both are needed. The LP rows stop a set-point degrading an MNEC; the objective term stops a topological action doing it, because nothing else in the tree looks at MNECs at all.

The baseline is the run's starting point, not the perimeter's

\(m_0\) and \(f_0\) come from the network before any remedial action, preventive ones included. A curative MNEC is judged against the margin it had with nothing applied, so the baseline is measured once by castor::run on the untouched network and carried into every perimeter. Measuring it per perimeter would judge a curative MNEC against whatever the preventive stage left it at — which is exactly the degradation the constraint exists to forbid.

Why it iterates

The linearization is exact in DC for a redispatch, because DC flow is linear in injection. It is not exact for a phase shifter, for a reason that is easy to state backwards.

The sensitivity itself is constant in DC: it depends on the network's susceptances, not on the shift. What is not linear is the tap-to-angle map. A tap changer offers a finite set of angles, so the continuous optimum lands between two of them, and rounding puts the network somewhere the LP did not ask for. Sensitivities computed at the old point are then slightly wrong about the new one.

So the loop is: solve, apply, recompute, and keep the result only if the true minimum margin improved. The acceptance test is run on the evaluator's margin, not on the LP's objective value — the LP's own number is the answer to the linearized problem, which is the thing under suspicion.

A worked example

The chapter's network, the ±700 MW CNEC on BE2–DE1 from the previous page, and one range action: the phase shifter BBE1AA1–BBE2AA1, free over its full ±16 taps.

The program, written out

One action, one CNEC, two finite bounds: 4 columns and 4 rows.

\[ \begin{align*} \text{minimize } \quad & -MM + 0.01,(\Delta^{+} + \Delta^{-}) \\ \text{subject to } \quad & A - \Delta^{+} + \Delta^{-} = -3.116 \\ & MM \le 700 - \bigl(814.0 - 100.79,[A - (-3.116)]\bigr) \\ & MM \le \bigl(814.0 - 100.79,[A + 3.116]\bigr) + 700 \end{align*} \]

The set-point is written in the CRAC's sign convention, in which the file's tap −8 reads as −3.116° and increasing \(A\) reduces the BE2–DE1 flow. (gridoxide's internal transformer angle is the negation of IIDM's, which is what a CRAC is written in; tap_table does the negation once, on the way in.)

Solving it on paper

Write \(\delta = A + 3.116\) for the movement in degrees. The flow is \(F = 814.0 - 100.79,\delta\) and the two bounds give

\[ MM \le 700 - F = -114.0 + 100.79,\delta, \qquad MM \le F + 700 = 1514.0 - 100.79,\delta \]

\(MM\) is the lower of two lines, one rising and one falling, so the maximum sits where they cross:

\[ -114.0 + 100.79,\delta = 1514.0 - 100.79,\delta \quad\Longrightarrow\quad \delta = \frac{1628.0}{2 \times 100.79} = 8.076° \]

which is \(F = 0\) and \(MM = 700\) MW — the flow driven to zero, both bounds equally far away. That is \(A = 8.076 - 3.116 = 4.960°\).

What the taps allow

4.960° is not a tap. The UCTE ##R record gives a symmetric 16-position changer whose nearest positions are

TapAngle
124.6727°
135.0617°
145.4504°

Rounding to the nearest gives tap 13, and the re-evaluation is what decides whether that was the right rounding:

\[ \begin{align*} \text{tap } 13:\quad & \delta = 8.178°,\quad F = 814.0 - 824.3 = -10.3\ \text{MW},\quad m = 689.7 \\ \text{tap } 12:\quad & \delta = 7.789°,\quad F = 814.0 - 785.1 = +28.9\ \text{MW},\quad m = 671.1 \end{align*} \]

Note that at tap 13 the flow has crossed zero and the lower bound is now the binding one — \(m = \min(700 - (-10.3),\ -10.3 + 700) = \min(710.3,\ 689.7)\). A margin computed as \(\text{limit} - \vert F \vert\) would have given the same answer here, because the bounds are symmetric; on a one-sided CNEC it would not.

An action that cannot be where it is

Before any of that, a range action whose starting set-point is already outside its own range is dropped from the perimeter. A CRAC saying "this device may only be at positions it is not at" is not describing a tightly-constrained lever; it is describing no lever, and no movement the optimizer chooses can make the statement true.

This only bites when an earlier perimeter has moved the device, and then it matters more than it sounds — because several range actions may name the same network element. The reference's SL_ep15us11-3case2_withPstCra declares four on one phase shifter and names one of them useless_pst, permitting tap 0 and nothing else; an automaton has put that shifter on tap −8 by the time the curative perimeter runs. Kept, it is not inert: it becomes a second column driving a device that already has one, pinned to a position the machine is not at, and the perimeter moves nothing and reports that nothing helped.

Three ways to say how far it may move

A range is a pair of bounds and an anchor, and the CRAC names the anchor per range:

kindanchored onmeans
absolutenothingthe bounds are tap positions
relativeToInitialNetworkthe tap in the network as imported"no more than n taps from where the file had it"
relativeToPreviousInstantthe tap this perimeter began at"no more than n taps from whatever the plan already did"

An action carries several ranges and they are intersected, so the binding one wins. The two relative kinds coincide for a preventive perimeter, whose previous instant is the network as imported, and part company for a curative one — which is the whole reason a CRAC bothers to write both. SL_ep13us5case3 in the reference's own suite declares all three on one shifter: absolute ([-16, 16]), ten taps of the imported network's 5, and ten taps of the preventive answer (-5). The intersection is ([-5, 5]), narrower than any of them alone.

The anchor for the last kind is measured once, on the network the perimeter was handed — before any leaf has applied a set-point and before the outer iteration has moved anything. It is not the live tap. Anchoring on the live tap lets the box walk one width per iteration, until the answer bears no relation to what the CRAC allowed; and reading the kind as absolute, which is what an unhandled range kind degenerates to, silently shrinks the permission instead. On scenario 1.3.4.3 that second failure stops the optimizer at tap 10 where 15 was allowed, with five taps of travel it never knew it had — and nothing about it is visible in a margin, because the answer stays feasible, self-consistent and worse.

What the code does

$ gridoxide rao tests/data/benchmark-grids/powsybl-open-rao/files/cases/flowbased_computation/3nodes_pst.uct \
      --crac docs/examples/pst-worked-example.crac.json

preventive perimeter (1 state(s)): -114.0 -> 689.7 MW (+803.7), 0 leaf/leaves
  SET    PST-BE to tap 13 (5.062 deg, was -3.116)

SECURE: worst margin 689.7 MW (was -114.0)

Tap 13, 5.062°, margin 689.7 MW. 0 leaf/leaves because this CRAC declares no network actions, so the search tree has nothing discrete to try and the answer is the LP's alone.

Two things this small case shows that a larger one hides:

  • The optimizer does not stop at secure. A margin of 0 was reachable at \(\delta = 1.131°\), roughly tap −5. The default objective is to maximize the worst margin, so it goes to 689.7 instead. Whether that is the right behaviour is a policy question the CRAC answers — see secure is enough.
  • The penalty is invisible here and would not be with two shifters. \(0.01 \times 8.178 = 0.08\) against an objective of 689.7. It exists to break ties, and among equally good answers the one that moves least wins.

Discrete taps, optionally

TapModel::Continuous (the default) optimizes the angle as a real number and rounds, as above. It needs only an LP, so it runs on gridoxide's own interior-point solver.

TapModel::Discrete optimizes the tap itself as an integer variable. That needs a MIP backend, and IpmSolver refuses rather than relaxing the integrality silently — see the OPF solver boundary, which this layer shares.

What is not here

Network actions. This layer moves continuous set-points; choosing which discrete actions to take is the search tree's job, and the two interleave rather than run in sequence.

Cross-perimeter range actions in one problem. One perimeter at a time: the preventive perimeter is solved, its decisions are fixed, and each curative perimeter is solved against them. That is the CASTOR decomposition, not an approximation of it — but it means a curative set-point cannot be traded against a preventive one inside a single LP. What the ranges below chain is the bound, not the variable.

HVDC range actions are recognised and skipped. A counter trade has no network sensitivity at all, which is why the reference leaves it out of its LP too.

The Search Tree and the CASTOR Decomposition

Range actions have a degree of freedom and go to an LP. Network actions do not: a switch is open or closed and there is no gradient between the two. So the discrete half is searched, and the perimeters that searching happens in are chosen by a decomposition that is itself part of the answer.

The greedy tree

evaluate(root); optimize_range_actions(root)
for depth in 0..D:
    for each available action not yet applied:
        leaf = root + action
        evaluate(leaf)
        optimize_range_actions(leaf)      <- the full LP, every leaf
    keep the best leaf if it improved enough; else stop

With \(N\) available actions and a depth limit \(D\), that is at most

\[ \sum_{d=0}^{D-1} (N - d) ;\approx; ND - \tfrac{D(D-1)}{2} \]

leaves — linear in \(N\) for fixed \(D\), against the \(\binom{N}{D}\) of an exhaustive search. The price is the usual one: greedy picks the best single action at each level, and the best pair is not always two actions that are individually best. SearchResult::leaves reports the count actually evaluated, and it is the number to watch when a case gets big.

The default depth is 2, and a curative perimeter may be given its own. The reference keeps max-preventive-search-tree-depth and max-curative-search-tree-depth apart because they answer different questions: how much may be planned in advance, against how much may be carried out under time pressure by people who did not plan it.

Combinations are configuration, not algorithm

The greedy price above is real but smaller than it looks, because the reference is greedy too. SearchTreeBloomer.bloom returns exactly two things: one candidate per individual network action, and each predefined combination named in the RAO parameters. It never enumerates subsets. So predefined-combinations is the whole of its breadth, and gridoxide offers the same:

candidates = every individual action + every predefined combination

A combination is one candidate. It costs one depth, not one per member; it spends one usage allowance per member, because the limits count remedial actions and taking three at once is still taking three; and it is taken whole or not at all. An entry naming an unknown action, naming fewer than two, whose members conflict with one another, or any of whose members is unavailable or inexpressible in this perimeter is dropped entirely — half a combination is a network nobody described, which is the rule elementary actions inside a single network action already follow one level down.

Worth stating because it settled an argument: this parameter is [] in every configuration the reference ships with its own test suite. So on the scenarios where the two implementations disagree, the reference reaches its answer with the same single-action chain — and a plan to build a combinatorial search would have been closing a gap that is not there.

Every leaf re-runs the whole linear optimization

This is where all the time goes, which makes it tempting to skip. Do not.

A topological action changes the sensitivities \(\sigma(r, c)\) the phase shifters are optimized against. Choose the topology first and the set-points afterwards, and an action that looks poor on its own never gets the chance to be the best one once the shifters are re-tuned around it. The two decisions interact, so they have to be made together.

When a candidate is good enough to take

Two thresholds, both applied, the stricter binding. A candidate is taken only if it improves the objective by at least

\[ \Delta ;\ge; \max\bigl(, \varepsilon_{abs},;; \varepsilon_{rel} \times \vert \text{objective} \vert ,\bigr) \]

with \(\varepsilon_{abs}\) the CRAC's absolute_min_impact and \(\varepsilon_{rel}\) its relative_min_impact.

Both default to zero, which takes any improvement at all. They exist because a real study does not want a remedial action reported for a gain of 0.3 MW: the action has a cost the optimizer cannot see, and an operator asked to switch a busbar for a rounding error will stop trusting the tool. Both are measured in the objective's unit, so a threshold of 275 means 275 MW or 275 A depending on the flow model.

Stopping when secure is enough

stop_at_target replaces "maximize" with "reach this value and stop". The reference's SECURE_FLOW objective is exactly this, with a target of zero: secure is enough.

It is not a weaker version of maximizing, and it changes two things:

  • A network that already has a positive margin gets no remedial action at all.
  • A candidate that reaches security is taken whether or not it clears the minimum-impact thresholds.

Twelve of the reference's own AC scenarios use it, and one asserts that zero actions are used on a network gridoxide would happily have improved. Spending remedial actions to gain margin nobody asked for is a different answer, not a better one. The worked example shows the other default in action: it drives a −114 MW margin to +689.7 MW when +0 would have done.

Skipping actions far from the problem

skip_far_actions: Some(k) discards candidate actions more than \(k\) country boundaries away from the most limiting element; Some(0) keeps only actions in the same country as the worst CNEC.

This exists because an operator in one control area cannot generally be asked to act for an overload in another — and it changes the answer. Without it the search takes actions the reference never offers itself and reports a better margin than the problem actually allows. It needs Network::bus_countries; with no countries there is no notion of far and the filter passes everything.

Determinism under parallelism

Candidates are evaluated in a fixed order and ties break on the action id, so a run reproduces itself. This matters more than it sounds: a search tree that returns a different answer each time cannot be regression-tested, and an operator cannot be told why yesterday's study disagreed with today's.

Which actions are on the table

Not every action a CRAC contains is available in every state. Its usage rules say when, and they come in two kinds.

onInstant and onContingencyState are topological — a state alone answers them. onFlowConstraint and onFlowConstraintInCountry are not: they say the action is available only if some CNEC is actually constrained, which takes flows. A TSO writes those to mean "I will open this line, but only if that line is overloaded" — an action nobody would take otherwise, and one no operator would be offered otherwise.

Three details decide the answer, and each of them is a way to get it wrong:

Any rule, not every rule. An action is available when one of its rules is activated. So a conditional rule is another way in, never a restriction: an action carrying both onInstant: preventive and onFlowConstraint is available in every preventive state whatever the flows say.

Constrained means margin ≤ 0, in the objective's unit. Not < 0 — a CNEC sitting exactly on its threshold authorizes its action — and the unit matters for the same reason it matters to the objective, since a margin that is negative in amperes can be positive in megawatts.

Measured once, at the perimeter's starting point, and never re-derived. This is the one that looks like a bug and is not. An action authorized by an overload keeps its authority even after some other action relieves that overload. Re-deriving availability inside each leaf would let the candidate set change underneath the search — a different problem at every depth, whose answer depends on the order the actions happened to be tried. The reference names a scenario after it: 2.4.1.2, "onConstraint RAs with a constraint triggered by another preventive RA, no reevaluation".

Getting this wrong is expensive and flattering. Before the flow half existed, gridoxide offered every conditional action unconditionally, and on that scenario used three actions for +97 A where the reference uses one and reports −45. A gate reading only margins would have called that an improvement.

Usage limits

A CRAC may cap how much may be done at all, per instant: a total max_ra, a number of TSOs allowed to act at all (max_tso), and per-TSO caps on topological actions, PSTs, remedial actions and elementary actions. These are constraints on the plan, not on any one CNEC, and they are what keeps an optimizer from proposing a coordinated fourteen-action manoeuvre across five countries because it gained 20 MW.

One budget, spent by both halves

The limits count remedial actions, and a phase shifter that moves is one exactly as much as an opened line is. So the two halves of the search spend a single allowance between them and neither can be limited on its own. On scenario 2.6.1.3 the whole curative allowance is one action and the reference spends it on the shifter, while gridoxide — counting nothing — opened a line and moved the shifter, then reported a margin for a plan the CRAC forbids.

Enforcement is split to match. Growing the tree, a candidate is rejected before it is ever evaluated if the set it would make exceeds max_ra, max_topo_per_tso, max_ra_per_tso or max_elementary_per_tso — a plan the CRAC forbids is not a worse answer, it is not an answer. What those network actions leave over becomes the leaf's budget for range actions, and the subtraction is asymmetric on purpose: max_ra and max_ra_per_tso count everything, so topology eats into them, while max_pst_per_tso counts only shifters and is untouched by topology.

The budget is a cardinality constraint on which range actions may move, which the reference expresses with a binary per range action and a MIP. gridoxide does not need one: the largest vendored CRAC has four range actions in a perimeter and every one that declares a limit has two, so enumerating the admissible subsets answers the same question exactly on the LP solver already in hand. It runs only when the unconstrained answer turns out inadmissible, so the ordinary case pays one comparison.

max-tso is deliberately absent. A CRAC may declare it and the reference ignores it outright — "a max-tso limit can no longer be defined and will be ignored" — so honouring it would apply a constraint the reference does not.

When a curative perimeter stops

A curative perimeter is not asked for the best answer it can find. It is asked whether the post-contingency state is at least as good as the one the preventive stage already accepted, and it stops as soon as it is.

That is the reference's rule, and it is unconditional: TreeParameters gives every curative perimeter AT_TARGET_OBJECTIVE_VALUE, where the preventive perimeter gets it only under SECURE_FLOW. The target is

\[ \text{objective} ;\ge; \text{preventive objective} ;+; \delta \]

with \(\delta\) the curative-min-obj-improvement, and \(\max(\cdot, 0)\) applied on top when enforce-curative-security is set. The reasoning is operational rather than mathematical: curative actions are carried out under time pressure by people who did not plan them, so an extra 40 A bought by a third switching operation is not worth having.

Two details decide how much of the answer this moves. The reference's default \(\delta\) is zero — "better than preventive at all" is the ordinary rule, and the 10000 in its own test configurations is what turns the rule off. And the criterion is tested on the root leaf before that leaf's range actions are optimized, so a perimeter that already qualifies is left entirely alone: no network action and no tap movement either. That is the difference between two curative remedial actions and none.

An action is applied whole, or refused

A network action is a set of elementary actions taken together — "split this busbar" is one decision, not six. An action containing an elementary action gridoxide cannot express is therefore rejected outright rather than applied partially. Applying half of one produces a network the CRAC never described, and an answer that references an action whose effect was not what the optimizer measured.

CASTOR: which perimeters there are

The search answers one perimeter. Deciding which perimeters there are and in what order is the difference between a set of independent answers and a plan.

The preventive perimeter is the base case together with every outage state. Both are secured by the same actions, because an outage instant is too soon for anyone to do anything: whatever protects it must already have been done.

Each contingency then gets its own curative perimeters, one per curative instant that has an action available, solved in chronological order with the preventive decisions applied and each instant's result fixed before the next begins.

                    ┌──────────────────────────────────────────┐
   preventive       │ base case + every outage state           │  one set of actions
                    └──────────────────┬───────────────────────┘
                                       │  decisions carried forward
              ┌────────────────────────┼────────────────────────┐
              ▼                        ▼                        ▼
        contingency A            contingency B            contingency C
        auto → curative          auto → curative          auto → curative
        (its own perimeters, chronological, independent of the others)

How the decisions are carried forward

A curative perimeter inherits an open set: the network file's own out-of-service circuits, whatever the preventive stage opened, and whatever the automatons opened or closed. That set is passed to the search as a list, and this is the one implementation detail in this layer worth stating in a book, because the obvious alternative is wrong in a way that produces no error.

The alternative is to bake the set into the network — write a 10⁹ Ω impedance into a copy of the lines and search the result. It reads as equivalent. It is not, because a closing remedial action is expressed by removing a branch from the open set:

open = base_open ∪ action.open ∖ action.close

With the set already spent on the impedances, base_open is empty, the removal removes nothing, and the branch stays at 10⁹ Ω whatever the CRAC says. The action is not refused — it evaluates as a change that does nothing, loses to every candidate that does something, and the perimeter reports that no remedial action was worth taking. Every margin downstream stays perfectly self-consistent while describing a network in which the standby circuit was never reconnected.

Against the reference's suite that was 21 curative closes declined out of 21 offered, while the same actions were taken 8 times out of 8 in the preventive perimeter and 3 out of 3 by automatons — which is how it was found. The automaton simulator had always carried its open set as a list; only the curative path had not.

The same rule governs what a perimeter hands the next one: the effective set is the search's own result, never a union with what went in. A union is safe only while nothing can ever be closed, since it puts back exactly the branch the closing action just shut.

Why curative actions are not optimized jointly

Refusing to put every contingency's curative actions into one problem is not an approximation for speed. Curative actions for different contingencies are never taken together — only one contingency happens — so optimizing them jointly would let the answer trade one against another, which is meaningless. The decomposition is the correct model, not a relaxation of one.

The pull-forward rule

A curative CNEC for which no curative action exists cannot be secured after the fact, so it is moved into the preventive perimeter.

Without this the preventive optimization is free to park a flow between the PATL (the permanent limit) and the TATL (the temporary one) — acceptable at the outage instant, when the temporary limit applies, and permanently overloaded afterwards with nothing available to fix it. It is the one rule in this layer that changes an answer rather than just organising the work, and Plan::pulled_forward reports where it applied.

Automatons: simulated, never optimized

The auto instant is not a decision. A protection scheme fires when its trigger condition is met, whether or not that helps anything else, and the job is to reproduce what the equipment does rather than to choose what it should do.

Automatons fire in ascending order of speed, in batches, and the trigger conditions are re-evaluated between batches but not within one. An automaton that states no speed is read as speed zero, so it fires first — before everything that named a speed at all. The opposite reading is the tempting one ("no speed stated" sounds like "no claim to be fast") and it changes answers, not just order: on scenario 1.2.2.4 an untimed automaton opens a Belgian circuit, and the two phase shifters that follow are sized against the network that leaves behind. Fired last instead, they size themselves against an overload the line opening was about to remove and spend four taps where one is enough. Actions that all omit the speed still share one batch, so the sampling rule below is untouched.

Both halves of that rule are load-bearing:

  • Re-evaluating between batches is why a fast automaton that relieves an overload stops a slower one from ever seeing the condition that would have triggered it.
  • Not re-evaluating within a batch is why an automaton whose CNEC is healthy when the batch begins stays out of it, even if a sibling firing alongside pushes that CNEC into overload. Equipment that sampled the grid simultaneously would not see that, and the reference does not fire it.

Range actions are the exception, and the reference is explicit about the asymmetry: first all automatic network actions are applied, then automatic range actions one by one for as long as CNECs remain overloaded. A range action has a set-point to size against the flows as they stand, so it necessarily sees the state its predecessors left. Its set-point comes from a formula rather than an LP:

\[ A_{\text{new}} ;=; A_{\text{current}} ;+; \operatorname{sign}\bigl(F(c)\bigr), \frac{\min\bigl(0,\ m(c)\bigr)}{\sigma} \]

taking \(c\) as the worst-overloaded CNEC the action is watching, ranked in the unit the flow model implies — megawatts under DC, amperes under AC, since two CNECs at different voltages order differently in the two. \(\min(0, m)\) is zero on a healthy CNEC, so a triggered action whose CNEC is no longer overloaded moves nothing. The result is capped by the action's own range: an automaton is not exempt from the permission a TSO wrote for it, and without the cap it stops only when it runs out of tap changer.

One shot is not enough, and why the answer is measured rather than computed

That formula is a linear estimate applied to a network that is not linear, so a single shift is systematically wrong in whichever direction the curvature runs. It is not a rounding-error effect: on the reference's own scenario 1.2.2.2 one shot lands on tap −7 with the watched circuit still over its limit, where −8 clears it. So the shift iterates — re-measure, re-size, shift again — and stops on whichever comes first:

  • nothing it watches is overloaded any more (the goal is a secure perimeter, not the best margin available: an automaton is protection equipment, and it acts until the thing it watches is inside its limit);
  • the estimate asks for a move back the way it came (a scheme does not hunt);
  • an iteration guard.

There is one deliberate departure from the reference here, and it is worth stating plainly. The reference divides by a sensitivity taken from the same analysis that measures the flow, so its computed set-point is the smallest one that secures the circuit and rounding to the next tap away from the start costs at most one position. gridoxide's gradient is the DC phase-shift sensitivity, and under an AC flow model that is a different number — 5.44 MW per degree against a delivered 8.8 on 1.2.2.2 — right in direction and 60% too far in distance. Rounding away then compounds the error into several taps, and nothing downstream can catch it: unlike the search tree, this layer has no keep-it-only-if-it-improved filter, so whatever it lands on is the answer.

So gridoxide reproduces the specification rather than the arithmetic. "Shift until the circuits are secure and no further" is a claim about measured flows, so the estimate is used to get into the neighbourhood and the tap is then settled by measuring: the position nearest the starting point that secures everything watched wins. That makes the answer independent of how good the gradient was, which is the property the reference gets for free by having an exact one.

The last check: was it worth doing?

Every perimeter accepts only candidates that improve its own objective, so it looks as though the finished plan cannot come out worse than the network it started from. It can, because the perimeters do not partition the harm.

A preventive action is judged on the base case and the outage states. What it costs a curative state is invisible there — and by the time a curative perimeter sees it, the preventive decisions are fixed and it can only make the best of them. The reference's scenario 1.4.4.2 is built on exactly that shape:

                        preventive perimeter      curative state
  do nothing                    590.6 MW               (secure, +113 A overall)
  close two circuits            681.7 MW   ← improved       −342 MW   ← wrecked
  curative recovers what it can                            −167.7 MW

Both perimeters did their jobs and the plan still ends below where it began. So the last thing the optimization does is compare the finished plan against the untouched network and, if it lost ground, throw the whole plan away and report doing nothing. The comparison is on the objective rather than on the megawatt margin: under an ampere objective two CNECs at different voltages order differently in the two units, and a monitored CNEC's violation is part of the cost at stake.

Two details of what "throw it away" means. The automatons go with it — which is the reference's own behaviour, and worth flagging as a position rather than an accident, since an automaton is not a choice and an operator reading the plan is being told what the optimizer decided rather than what the equipment will do. And the leaf counts survive: they record what the search evaluated on the way to deciding, and the search did evaluate them.

Second preventive

The decomposition is sequential, and that is its one weakness: the first preventive perimeter is judged on the base case and the outage states, so a curative constraint no curative action can fix is invisible to it — it is not in the perimeter, and the perimeter that does contain it comes later and cannot revisit preventive decisions.

So the preventive perimeter is optimized a second time, with three things changed:

  • every CNEC is optimized, whatever its state — the whole point, since the curative constraints are now in front of the preventive optimizer;
  • the automatons are held applied, so it works on what they cannot fix;
  • the curative decisions are held applied too, for the same reason.

It runs only when the configuration asks and the first pass leaves something to gain — either a curative perimeter that did not reach its stop criterion, or a plan that ended up costing more than doing nothing. What it produces is kept only if the whole plan it leads to is better, judged the same way a plan is judged against doing nothing.

Two things fall out of "the preventive perimeter, optimizing every CNEC". It optimizes every state while remaining a preventive perimeter, so which CNECs are optimized and which actions may be taken stop being the same question — the second is still the preventive instant's. And the held decisions have to be stripped back out of its answer: they were in force so the pass could see past them, and leaving them in would put one contingency's curative switching into the preventive plan, hence into force for every other contingency.

What the reference does here and this does not is re-optimize the curative range actions inside that same problem, which needs a set-point per action per state. It has been attempted, and the more interesting half of that attempt is why it does not pay yet: the reference keeps its curative answer afterwards because its second preventive computed one, and gridoxide's curative search — a full search tree, with its own stop criterion and per-leaf LP — is currently better than any curative column its second preventive problem can produce.

Reading the plan

$ gridoxide rao tests/data/benchmark-grids/powsybl-open-rao/modules/commons/src/test/resources/TestCase12Nodes.uct \
      --crac tests/data/benchmark-grids/powsybl-open-rao/files/crac/epic15/jsonCrac_ep15us11-4case1.json --depth 2

preventive perimeter (2 state(s)): -182.3 -> -82.9 MW (+99.4), 3 leaf/leaves
  APPLY  Open line NL1-NL2
  SET    PRA_PST_BE to tap -16 (-6.228 deg, was -0.000)

curative after Contingency_FR1_FR3: -89.2 -> -89.2 MW (+0.0), 1 leaf/leaves
  (nothing available helps)

INSECURE: worst margin -89.2 MW (was -182.3)

Each perimeter reports the states it secures, the worst margin before and after, and the leaves it cost. The exit code is 0 when every perimeter ends secure and 1 when any does not, so the command can gate a pipeline the same way gridoxide security does.

This run shows both halves of an honest answer. The preventive perimeter improves by 99.4 MW using a topological action and a phase shifter together — the PST went to its limit at tap −16 — and is still short of secure. The curative perimeter then reports that nothing available helps: it evaluated its one candidate, took nothing, and left the margin where it found it. (nothing available helps) is a result, not a failure; it is the answer when the actions on offer cannot reach the problem.

PerimeterPlan additionally carries the buses and transformers as the perimeter leaves them, which together with open_branches is the network its figures describe. That is what lets a caller re-derive any quantity the plan does not itself report — a per-CNEC margin, say — instead of reconstructing a network that drifts from the one the searcher measured. A redispatch lives only in the buses, so a consumer that carries the transformers and forgets the buses reproduces a network in which no injection ever moved.

Backends and Factorization Reuse

The Y-bus admittance matrix and Newton-Raphson Jacobian are stored and factored as sparse matrices, not dense ones. This page covers why that matters, how factorization work is reused across solves, and what the five interchangeable linear-solver backends are.

Why sparse

At 2,605 nodes the original dense solver was over 100,000x slower than power-grid-model; the sparse rewrite closed that to roughly an order of magnitude on a cold, single-shot solve. The underlying grid is sparse — each bus only connects to a handful of neighbors — so a dense representation was doing asymptotically unnecessary work regardless of how fast the constant-factor arithmetic was.

Two things make this work, rather than just "swap in a sparse matrix type":

  • Sparse-aware assembly. Both the Jacobian build (solver::build_jacobian_triplets) and the linear initial-guess warm start (network::linear_initial_guess) walk each bus's actual admittance neighbors (via network::YBusSparse::row) instead of looping over every possible bus pair. The O(n²)/O(m²) assembly cost has to go too, or a sparse solve alone doesn't fix the bottleneck.
  • Symbolic factorization reuse. A Newton-Raphson Jacobian has the same sparsity pattern every iteration — same bus topology, only numeric values change — so solver::newton_raphson computes the symbolic factorization (fill-reducing ordering) once and reuses it for a cheap numeric-only refactorization on each iteration (sparse::RealSparseSystem), mirroring what PGM's own solver does internally.

Inside KLU walks through exactly what "symbolic factorization" and "numeric-only refactorization" mean, step by step, on a real Jacobian.

Reusing factorization across repeated solves

A single newton_raphson/newton_raphson_with_backend call reuses its symbolic factorization across its own NR iterations, but starts cold on every call — re-deriving the fill-reducing ordering from scratch. That is fine for a genuinely one-off solve and wasteful for anything that solves the same topology repeatedly: a time series, a batch of scenarios, contingency analysis. In those, only bus values (p_spec, q_spec, voltage guess) change between calls, not the topology.

solver::PersistentSolver extends the reuse across calls. Construct one per topology, then solve as many times as needed; only the first call pays for symbolic factorization.

#![allow(unused)]
fn main() {
use gridoxide::solver::{JacobianBackend, PersistentSolver};

let mut solver = PersistentSolver::new(JacobianBackend::Klu);
for scenario in scenarios {
    apply_scenario(&mut buses, scenario); // changes p_spec/q_spec only
    solver.solve(&mut buses, &ybus, 1e-6, 20);
}
}

Call .reset() (or construct a new solver) if the topology itself changes between solves. This is a meaningful win on real-world grids, since a cold solve otherwise redoes COLAMD/AMD/BTF ordering from scratch every call. examples/bench_network.rs exposes it as an optional warm mode; its default cold mode still measures "N independent flat-start solves with no shared state," a different and also legitimate number. See Benchmarking for the measured warm-vs-cold figures.

The backend interface

crates/gridoxide-sparse/src/sparse.rs is the thin backend wrapper around faer, and intentionally the only file that imports faer types directly, so a different sparse-solver backend can be swapped in behind the same interface without touching the rest of the codebase.

The default backend is KluNative, the Rust port of KLU: on the power-flow Jacobians gridoxide is benchmarked on it is about six times faster than Scalar (case9241pegase: 15 ms against 87) and within 15% of SuiteSparse's own KLU, with no build requirement. It scales rows and falls back to a fresh factorization when a refactor meets a zero pivot, as SuiteSparse's KLU is used. solver::newton_raphson uses it, as does every Default that names a backend (PowerFlowOptions, SeOptions, DynamicsOptions, the Python constructors, the C API's gridoxide_options_default). newton_raphson_with_backend accepts any of solver::JacobianBackend's variants: Scalar (faer) and the alternatives below.

Block

crates/gridoxide-sparse/src/block_sparse.rs, no extra build requirements. Groups each bus's own (angle, voltage-magnitude) unknowns into one dense 2×2 block, mirroring power-grid-model's block-per-bus matrix structure, with a hand-written Gilbert-Peierls sparse block LU (block_sparse::BlockLu). Symmetric power flow only. Consistently faster than Scalar.

Klu

crates/gridoxide-sparse/src/sparse_klu.rs, needs cargo build --features klu. The same scalar Jacobian as Scalar, solved by SuiteSparse's KLU instead of faer, vendored and compiled from source (crates/gridoxide-sparse/vendor/suitesparse/) rather than depending on a third-party Rust wrapper crate. Needs a C compiler and libclang (for bindgen) at build time.

KLU and BTF (one of KLU's own dependencies) are LGPL-2.1-or-later. See Provenance and Licensing.

KluNative

crates/gridoxide-sparse/src/klu_native/, no extra build requirements. A from-scratch Rust translation of the same KLU algorithm Klu links over FFI: BTF block-triangular preprocessing, per-block AMD ordering, a partial-pivoting Gilbert-Peierls LU kernel with Eisenstat-Liu pruning, and cheap numeric-only refactorization — all faithfully ported, not a simplified reimplementation. No C compiler or libclang needed, so unlike Klu it is always built.

Validated end-to-end against real KLU on all 13 real MATPOWER benchmark cases (14 to 9,241 buses): identical iteration counts and identical converged voltages on every case. One known, documented gap — row scaling (klu_native::scale) is ported and independently tested but not yet wired into the factor/refactor path (see crates/gridoxide-sparse/src/klu_native/mod.rs's module doc comment). That is a numerical-stability preconditioning step, not a correctness one, so it doesn't affect the results above.

Inside KLU is a walkthrough of this port specifically.

Pardiso

crates/gridoxide-sparse/src/sparse_pardiso.rs, needs cargo build --features pardiso and MKLROOT set at build time. The same scalar Jacobian as Scalar, solved by Intel oneMKL's PARDISO direct solver. Unlike Klu, nothing is vendored — MKL is proprietary (Intel Simplified Software License, not OSS), so this only dynamically links a locally-installed oneMKL (libmkl_rt.so, discovered via MKLROOT, e.g. source /opt/intel/oneapi/setvars.sh) and generates FFI bindings via bindgen against that install's own mkl_pardiso.h. No MKL header or source is copied into this repo.

PARDISO's C API is one function called repeatedly with different phase values against a persistent opaque handle, rather than KLU's separate analyze/factor/refactor/solve functions. mtype = 11 (real, nonsymmetric) and iparm[34] = 1 (0-based indexing) are the two settings that matter for matching gridoxide's CSR/CSC conventions.

Not built or tested in CI — no CI runner has MKL installed — so this is a local/manual-verification-only backend.

How they compare

All four alternatives are strictly parallel to Scalar, not replacements. A bug in any of them can't affect newton_raphson's default behavior, and every existing test keeps using Scalar unless it explicitly opts into a different backend.

All five backends produce identical converged voltages at every scale — these are purely performance comparisons, not correctness trade-offs. In rough terms:

  • Block, Klu, and KluNative are all meaningfully faster than Scalar.
  • Klu and KluNative land close to each other, slightly ahead of Block.
  • Pardiso carries a largely size-independent fixed setup cost from its default matching/scaling preprocessing, making it the slowest backend at small problem sizes — even behind Scalar — though it scales better than Scalar as node count grows.

PGM is clearly faster than any gridoxide backend on synthetic radial-distribution/LV topology, a real, standing gap this project hasn't closed. That gap doesn't hold universally, though: on real-world transmission-topology grids, gridoxide's Klu backend is frequently faster than lightsim2grid's own KLU-backed C++ solver. The comparison depends on topology, not just implementation language.

Benchmarking points at the full measured numbers, exact ratios, and how to reproduce them.

Inside KLU: the Sparse Solve, Step by Step

Every Newton-Raphson iteration ends in the same place: solve \(\textbf{J} \Delta x = f(x)\) for the correction \(\Delta x\). For anything bigger than a toy network, that linear solve dominates the runtime, and the Jacobian is sparse — a bus only couples to its immediate neighbours, so a 22×22 Jacobian from an 11-node grid has 124 nonzeros out of 484 possible entries (74 % empty).

KLU is the sparse LU solver gridoxide uses for that step. It was designed by Tim Davis and Ekanathan Palamadai specifically for circuit-simulation matrices — very sparse, unsymmetric, with a strong zero-free diagonal — which describes a power-flow Jacobian almost exactly. gridoxide has two KLU backends (JacobianBackend::Klu, the vendored C via FFI, and JacobianBackend::KluNative, a from-scratch Rust port of the same algorithm in crates/gridoxide-sparse/src/klu_native/), and both run the pipeline described below.

This page walks that pipeline one phase at a time, on matrices small enough to check by hand. Every number shown was produced by running the actual crates/gridoxide-sparse/src/klu_native/ code on the matrix shown.

The pipeline at a glance

KLU splits the work into a symbolic phase that depends only on where the nonzeros are, and a numeric phase that depends on their values:

PhaseWhat it doesDepends onCode
0. CSC assemblytriplets → compressed sparse columnpatternklu_native/mod.rs
1. BTFpermute to block upper-triangular formpatternklu_native/btf/
2. AMDreorder inside each block to reduce fillpatternklu_native/amd/
3. FactorGilbert-Peierls LU with partial pivotingvaluesklu_native/kernel.rs, factor.rs
4. Solvepermuted forward/back substitutionvaluesklu_native/solve.rs
5. Refactorredo phase 3 with new values, same patternvaluesklu_native/refactor.rs

The split is the whole point. Phases 1–2 are the expensive combinatorial work, and in a power flow the Jacobian's pattern never changes between Newton iterations — only its numbers do. So gridoxide runs phases 0–2 once per topology (cached in PersistentSolver) and only phases 5 and 4 per iteration. Phase 5 exists precisely because it can skip everything phase 3 does symbolically.

Step 0: triplets to CSC

jacobian::JacobianPattern derives the Jacobian's sparsity pattern once per topology and hands the backend (row, col, value) triplets in whatever order its assembly walk happens to produce them — once, at LinearSolver::new. (solver::jacobian_triplets_reference/build_jacobian_triplets still assemble the same thing the naive way, but only under #[cfg(test)], as the oracle JacobianPattern is checked against bit-for-bit.) KLU wants compressed sparse column (CSC): one array of column start offsets, one of row indices sorted within each column, one of values.

Take this 4×4 matrix, which we'll reuse for the next several steps:

       c0  c1  c2  c3
r0      .   1   .   3
r1      .   .   2   .
r2      .   4   2   1
r3      5   .   1   .

As triplets and then as CSC (build_csc_structure in klu_native/mod.rs):

col_ptr = [0, 1, 3, 6, 8]     // column j occupies row_idx[col_ptr[j] .. col_ptr[j+1]]
row_idx = [3,  0, 2,  1, 2, 3,  0, 2]
values  = [5,  1, 4,  2, 2, 1,  3, 1]
          |c0|  c1  |    c2    |  c3 |

build_csc_structure also merges duplicate (row, col) pairs and returns a groups mapping from each CSC slot back to the triplets that fed it. That mapping is what makes cheap refactorization possible later: on the next Newton iteration, the same assembly order maps to the same CSC slots, so new values can be packed in without re-deriving the structure. It is also why later iterations hand over only the values — JacobianPattern refills one reused Vec<f64> positionally matching that first triplet order, and LinearSolver::factor_and_solve_values packs it straight into the cached slots, with no (row, col) pair rebuilt after the first iteration.

Step 1: BTF — finding block triangular structure

If a matrix can be permuted to block upper-triangular form, you never have to factor it as one piece. You factor each diagonal block independently and stitch the results together with back substitution. Blocks are smaller, so there's less fill and less work; and a 1×1 block is just a division.

BTF gets there in two moves.

1a. Maximum transversal: get a zero-free diagonal

First find a matching — a set of nonzeros, no two sharing a row or column, as large as possible. This is bipartite matching between rows and columns, solved by augmenting paths (btf/maxtrans.rs, a port of Duff's MC21 algorithm).

For the matrix above, the matching found is:

MAXTRANS: match = [1, 2, 3, 0]   nmatch = 4

Read it as "row i is matched to column match[i]": row 0 ↔ column 1 (value 1), row 1 ↔ column 2 (value 2), row 2 ↔ column 3 (value 1), row 3 ↔ column 0 (value 5). All four rows are matched (nmatch == n), so the matrix has full structural rank and can be permuted to have no zeros on its diagonal.

If nmatch < n, the matrix is structurally singular — no permutation can fill the diagonal — and btf_order completes the permutation arbitrarily so later phases still have a bijection to work with, marking the fake entries with the flip sentinel (klu_native/types.rs). The factorization then fails on a zero pivot, which is exactly how gridoxide reports SolveStatus::Singular for a disconnected island with no reference bus.

1b. Strongly connected components: find the blocks

With the matching in hand, build a directed graph: one node per column, and an edge \(j \to k\) whenever column \(j\) has a nonzero in the row that is matched to column \(k\). Its strongly connected components are exactly the irreducible diagonal blocks, and a topological order of the components gives the block ordering. btf/strongcomp.rs is Tarjan's algorithm, iterative rather than recursive.

For our matrix, the edges are:

c0 → c0                  (row 3 ↔ c0: the matched diagonal entry)
c1 → c3                  (row 2 ↔ c3)
c2 → c3, c2 → c0         (row 2 ↔ c3, row 3 ↔ c0)
c3 → c1                  (row 0 ↔ c1)

c1 → c3 → c1 is a cycle, so {c1, c3} is one component. {c0} and {c2} are singletons. And because c2 has edges out to both other components and none coming back, it must be ordered last. That is what the code returns:

BTF: p = [0, 2, 3, 1]   q = [1, 3, 0, 2]   r = [0, 2, 3, 4]

p and q are the row and column permutations; r gives the block boundaries — block 0 spans positions 0..2, block 1 is position 2, block 2 is position 3. Applying them:

                q0=c1 q1=c3 | q2=c0 | q3=c2
   p0=r0   [     1     3    |   .   |   .    ]
   p1=r2   [     4     1    |   .   |   2    ]
           [ --------------  -------  ------ ]
   p2=r3   [     .     .    |   5   |   1    ]
           [ --------------  -------  ------ ]
   p3=r1   [     .     .    |   .   |   2    ]

Block upper-triangular, with a 2×2 block and two 1×1 blocks. Instead of one 4×4 LU, KLU will do one 2×2 LU and two divisions. The two entries above the diagonal blocks (the 2 and the 1 in the last column) are stored separately, as the off-diagonal part — factor.rs keeps them in their own CSC arrays (off_p/off_i/off_x) because they're not part of any block's LU; they only appear during the solve.

What BTF does on a real power-flow Jacobian: nothing

Worth saying plainly, because it's the common case:

3-bus network.json:        n=3    nnz=9     btf_blocks=1   sizes=[3]
PGM transmission-case:     n=22   nnz=124   btf_blocks=1   sizes=[22]

A connected AC network has an irreducible Jacobian — every bus reaches every other bus through the network graph, so the whole matrix is one strongly connected component and BTF returns a single block. BTF earns its keep on circuit matrices with genuine one-way structure (a driving stage feeding a following stage that doesn't feed back).

The one power-flow case where it does something: a network with several islands that each have a reference bus. PersistentSolver::solve classifies islands but still hands the whole bus list to a single Newton solve (see Multi-Island Power Flow), so the Jacobian is block diagonal — no unknown in one island appears in any equation of another. The column digraph of step 1b then has no edges crossing island boundaries, so no strongly connected component can span two islands, and BTF necessarily recovers one block (or more) per island and factors them independently. Islands with no reference bus never reach this point: mark_unreferenced_islands converts their buses to slack first, removing them from the unknown set entirely.

BTF costs one near-linear pass, so KLU runs it unconditionally rather than trying to predict which case it's in.

Step 2: AMD — reordering to limit fill-in

Inside each diagonal block, the ordering still matters enormously, because of fill-in: entries that are zero in \(A\) but nonzero in \(L\) or \(U\). Fill is what makes a sparse factorization degenerate into a dense one.

The canonical demonstration is a star (an arrow matrix) — one hub node connected to everything, which in power system terms is a substation busbar with many feeders:

       c0  c1  c2  c3  c4
r0      5   1   1   1   1
r1      1   2   .   .   .
r2      1   .   2   .   .
r3      1   .   .   2   .
r4      1   .   .   .   2

Eliminate the hub (node 0) first and every pair of leaves becomes coupled — the remaining 4×4 block fills in completely. Eliminate the leaves first and nothing fills at all, because no two leaves are adjacent. AMD finds the second order:

AMD star perm = [4, 3, 2, 1, 0]         // hub eliminated last

star, natural order  [0,1,2,3,4]:  nnz(L)=10  nnz(U)=10  total LU nnz = 25   (fully dense)
star, AMD order      [4,3,2,1,0]:  nnz(L)=4   nnz(U)=4   total LU nnz = 13   (zero fill)

25 versus 13 on a 5×5 — and the gap widens quadratically with size. AMD (klu_native/amd/) is a greedy heuristic: repeatedly eliminate the node of smallest approximate degree, where "approximate" is the trick that makes it fast — it bounds each node's degree using quotient-graph element absorption rather than recomputing it exactly.

Two details of how KLU uses it:

  • AMD orders the symmetrized pattern \(A + A^T\), so it produces one permutation applied to both rows and columns of the block. That's why analyze.rs applies pblk to p and q identically.
  • Blocks of size ≤ 3 skip AMD and keep their natural order (analyze_worker's own threshold, matched exactly in analyze.rs) — for a 3×3 there is nothing to gain.

On the real Jacobians, this is where the savings actually come from:

PGM transmission-case (n=22, nnz=124):
    LU nnz with BTF+AMD = 124      (zero fill)
    LU nnz natural order = 308     (184 fill entries)

AMD ordered that Jacobian so well that the factorization produces no fill whatsoever — the LU factors have exactly as many nonzeros as the matrix. Natural order would have produced 2.5× more.

The 3-bus case is n=3, so it takes the natural-order path and both counts are 9 (a 3×3 Jacobian is dense anyway).

Step 3: numeric factorization — Gilbert-Peierls with partial pivoting

Now the values matter. KLU factors each diagonal block with a left-looking LU: column \(k\) of \(L\) and \(U\) is computed completely before column \(k+1\) is touched, using only columns to its left. kernel.rs::factor_block does this for one block.

Take this block (already ordered — pretend BTF and AMD have run):

       c0  c1  c2  c3
r0      2   .   1   .
r1      1   3   .   .
r2      .   1   4   1
r3      .   .   1   5

Each column goes through four sub-steps.

3a. Symbolic: which rows will this column touch?

Before computing anything, KLU determines the pattern of column \(k\) by a depth-first search. The rule (Gilbert-Peierls): the nonzero pattern of column \(k\) of \(L\) and \(U\) is the set of nodes reachable from the nonzero rows of \(A(:,k)\) in the directed graph of the already-computed \(L\).

Column 2 of the example shows why this matters. Its input entries are rows 0, 2, 3 — nothing in row 1. But row 0 is already pivotal (it was column 0's pivot), and column 0 of \(L\) has an entry in row 1. So the DFS reaches row 1 anyway, and column 2 gets an entry there that \(A\) never had. That's fill-in, predicted symbolically before a single flop:

U col 2: [(0, 1.0), (1, -0.5)]
                     ^^^^ fill: A(1,2) = 0

3b. Numeric: sparse triangular solve

With the pattern known, scatter \(A(:,k)\) into a dense workspace and run the updates in topological order — for each pivotal row \(j\) in the pattern, subtract \(x_j \cdot L(:,j)\). Column 2 again: \(x_0 = 1\), and \(L(1,0) = 0.5\), so \(x_1 \mathrel{-}= 0.5 \cdot 1 = -0.5\); then \(L(2,1) = 1/3\) gives \(x_2 = 4 + 1/6 = 4.1\overline{6}\).

3c. Pivot: pick the diagonal if you can live with it

Everything still below the diagonal is a pivot candidate. KLU's rule (lpivot) is diagonal preference with a threshold: take the entry the ordering intended for the diagonal, provided it's at least tol times the largest candidate in the column; otherwise take the largest.

tol defaults to 0.001 (klu_defaults.c, mirrored in types.rs), which is deliberately loose — KLU assumes its input has a strong diagonal and would rather preserve the fill-reducing ordering than chase the last digit of stability. Two 2×2 matrices show both sides:

A = [[0.5, 1],       diag candidate 0.5, column max 1.0
     [1.0, 1]]       0.5 ≥ 0.001 × 1.0  →  keep the diagonal
                     p = [0, 1]   udiag = [0.5, -1.0]

B = [[1e-6, 1],      diag candidate 1e-6, column max 1.0
     [1.0,  1]]      1e-6 < 0.001 × 1.0  →  pivot to row 1
                     p = [1, 0]   udiag = [1.0, 0.999999]

In the first case a dense LU with ordinary partial pivoting would have swapped rows (1.0 > 0.5); KLU does not, because 0.5 is good enough and keeping the row order keeps the sparsity. In the second case the diagonal is hopeless and it swaps — note the resulting \(L(1,0) = 10^{-6}\), tiny, which is the whole point of pivoting.

3d. Prune

After each pivot, prune applies Eisenstat-Liu symmetric pruning: once a column of \(L\) is known to be "covered" by a symmetric counterpart in \(U\), the DFS in step 3a no longer needs to scan past a certain point in it. This changes nothing about the result — it only shortens future searches, and it is why KLU's symbolic step stays near-linear instead of degrading on later columns.

The finished factors

For the 4×4 block above, no pivoting was needed (p = [0,1,2,3]) and the result is:

udiag  = [2, 3, 4.166666666666667, 4.76]

L col 0: [(1, 0.5)]                     U col 0: []
L col 1: [(2, 0.3333333333333333)]      U col 1: []
L col 2: [(3, 0.24)]                    U col 2: [(0, 1.0), (1, -0.5)]
L col 3: []                             U col 3: [(2, 1.0)]

i.e.

\[ L = \begin{bmatrix} 1 & & & \\ 0.5 & 1 & & \\ & \tfrac13 & 1 & \\ & & 0.24 & 1 \end{bmatrix}, \qquad U = \begin{bmatrix} 2 & & 1 & \\ & 3 & -0.5 & \\ & & 4.1\overline{6} & 1 \\ & & & 4.76 \end{bmatrix} \]

Checking against a dense solve with \(b = [1,2,3,4]^T\): both give \(x = [2/7,\ 4/7,\ 3/7,\ 5/7]\).

Note that \(L\)'s unit diagonal is never stored, and \(U\)'s diagonal lives in its own udiag array — that separation is what lets the solve divide without hunting for the diagonal entry inside a sparse column.

Step 4: solve — permutations, then blocks in reverse

With the factors in hand, solve.rs computes

\[ x = Q \left( (LU + \text{Off})^{-1} , P , b \right) \]

in four moves. Back to the BTF example from step 1, with \(b = [7, 4, 13, 10]^T\):

1. Permute the right-hand side. \(P b\) reorders b by p = [0,2,3,1]:

Pb = [b0, b2, b3, b1] = [7, 13, 10, 4]

2. Solve the blocks in reverse order. This is the part BTF bought us. An earlier block's rows may reference a later block's columns (that's what "upper block-triangular" means), so the last block must be solved first:

block 2  (1×1):   2·y3 = 4                        →  y3 = 2
block 1  (1×1):   5·y2 = 10 − 1·y3 = 8            →  y2 = 1.6
                        ^^^^^^^ off-diagonal entry, now that y3 is known
block 0  (2×2):   [1 3; 4 1]·[y0;y1] = [7; 13 − 2·y3] = [7; 9]

The subtractions are exactly the off-diagonal arrays from step 1 (off_x = [2.0, 1.0], both in the last permuted column) being applied as each block's solution becomes available.

3. Forward/back substitution inside each block. Block 0's factors are \(L = [[1,0],[4,1]]\), \(U = [[1,3],[0,-11]]\) (udiag = [1.0, -11.0]):

forward (Lz = rhs):   z0 = 7,   z1 = 9 − 4·7 = −19
back    (Uy = z):     y1 = −19 / −11 = 1.727273,   y0 = (7 − 3·1.727273) / 1 = 1.818182

4. Permute back. \(x = Q y\), i.e. x[q[k]] = y[k] with q = [1,3,0,2]:

x[1] = y0 = 1.818182
x[3] = y1 = 1.727273
x[0] = y2 = 1.6
x[2] = y3 = 2.0

x = [1.6, 1.8181818181818183, 2.0, 1.7272727272727273]

which matches a dense Gaussian-elimination solve of the original unpermuted matrix to the last digit but one ([1.6, 1.8181818181818181, 2.0, 1.7272727272727273] — the difference is one ulp of round-off, from a genuinely different order of operations).

Step 5: refactor — the one that runs every Newton iteration

Between two Newton iterations the Jacobian's values change but its pattern does not. Refactor exploits that as hard as it can: same BTF blocks, same AMD ordering, same pivot choices, same L/U sparsity pattern — only the numbers are recomputed.

That means refactor skips all of step 3a (no DFS, no reachability search) and all of step 3c (no pivot search). Real KLU's own klu_refactor.c never calls dfs/lsolve_symbolic at all, and neither does the port. The stored pattern of each \(U\) column is already in a valid topological order — it came from the original DFS — so the elimination can just walk it.

Keeping the earlier matrix's pattern and changing its values:

       c0  c1  c2   c3          before:  c0  c1  c2  c3
r0      .   1   .   3.5                   .   1   .   3
r1      .   .   3    .                    .   .   2   .
r2      .   4  2.5   2                    .   4   2   1
r3      5   .  1.5   .                    5   .   1   .
solve 1 (original values):  x = [1.6, 1.8181818181818183, 2.0, 1.7272727272727273]
solve 2 (new values):       x = [1.6, 1.6527777777777786, 1.3333333333333333, 1.5277777777777777]
     dense cross-check:         [1.6, 1.6527777777777781, 1.3333333333333333, 1.5277777777777777]

KluNativeSystem::factor_and_solve_values is the entry point the Newton loop uses: it packs the new values into the cached CSC slots (via groups from step 0), refactors in place, and solves. factor_and_solve is the same path for a caller that still holds full triplets — it reads nothing but their value component. The port's refactor_block_in_place overwrites the existing value fields rather than rebuilding Vecs — profiling showed those per-column allocations were the dominant reason the Rust backend ran ~2× slower than the C one.

The risk of reusing pivots is real but bounded: if the new values make an old pivot choice unstable, the result degrades rather than being caught by a fresh pivot search. factor_and_solve guards against the extreme case by rejecting a non-finite solution, and returns None so the caller can report SolveStatus::Singular.

The whole pipeline on a real Jacobian

The 3-bus network in tests/data/network.json (slack + PV + PQ) gives 2 angle unknowns and 1 magnitude unknown, so a 3×3 Jacobian. At the first Newton iteration from a linear initial guess (vm = [1.06, 1.04, 1.00307], va = [0, 0, −0.05160]):

J = [ 21.834690  −5.298690  −1.462838 ]
    [ −5.119349   9.032698   2.323778 ]
    [  2.005352  −3.538289   8.503522 ]

mismatch = [0.26866152, 0.00368908, 0.00153712]

Through the pipeline:

  • BTF: one block of size 3 (r = [0, 3]) — connected network, irreducible.
  • AMD: skipped, nk ≤ 3 → natural order. p = q = [0, 1, 2].
  • Factor: no pivoting needed (pnum = [0, 1, 2]) — the Jacobian's diagonal dominates, which is the property KLU's loose tol is built around.
k=0  udiag = 21.834690   L = [(1, −0.234459), (2, 0.091842)]   U = []
k=1  udiag =  7.790370   L = [(2, −0.391720)]                  U = [(0, −5.298690)]
k=2  udiag =  9.413792   L = []                                U = [(0, −1.462838), (1, 1.980802)]
  • Solve: \(\Delta x = [0.014383106, 0.008478649, 0.000316791]\) — matching a dense solve exactly. The first two entries are angle corrections in radians; the third is a voltage-magnitude correction in per unit.
  • Refactor: iteration 2 rebuilds J with the updated voltages and reuses everything above.

What this port deliberately leaves out

crates/gridoxide-sparse/src/klu_native/ implements KLU for exactly the configuration gridoxide uses, and the omissions are documented rather than silent:

  • Row scaling is ported but not wired in. scale.rs implements both of KLU's variants and is differentially tested against the C, but factor/refactor currently run as if scaling were disabled. Scaling changes which candidate pivots partial pivoting compares — a stability preconditioner, not a correctness requirement — and a per-unit power-flow Jacobian is not pathologically scaled. The differential tests (unscaled Rust vs. scaled C, same matrices) agree to 1e-8.
  • Real f64 only, single right-hand side, int32-range indices. No complex arithmetic, no batched multi-RHS, no DLONG. Newton-Raphson needs one real solve per iteration.
  • AMD only. COLAMD, user-supplied orderings, and the user-callback ordering are all reachable in real KLU but never selected by gridoxide, so Options has no ordering field to select them.
  • No maxwork limit on the maximum transversal — it defaults to "no limit" upstream and is never overridden, so the port always runs the matching to completion.

Where to look in the code

FilePortsContents
crates/gridoxide-sparse/src/klu_native/types.rsklu_internal.h, klu_defaults.cEMPTY/flip sentinels, Options
crates/gridoxide-sparse/src/klu_native/btf/maxtrans.rsbtf_maxtrans.cstep 1a, bipartite matching
crates/gridoxide-sparse/src/klu_native/btf/strongcomp.rsbtf_strongcomp.cstep 1b, Tarjan SCC
crates/gridoxide-sparse/src/klu_native/btf/mod.rsbtf_order.cstep 1, both halves combined
crates/gridoxide-sparse/src/klu_native/amd/amd_order.c, amd_2.cstep 2, approximate minimum degree
crates/gridoxide-sparse/src/klu_native/analyze.rsklu_analyze.csteps 1–2 driver, produces Symbolic
crates/gridoxide-sparse/src/klu_native/kernel.rsklu_kernel.cstep 3, one block: DFS, pivot, prune
crates/gridoxide-sparse/src/klu_native/factor.rsklu_factor.cstep 3 driver, off-diagonal bookkeeping
crates/gridoxide-sparse/src/klu_native/scale.rsklu_scale.crow scaling (not wired in)
crates/gridoxide-sparse/src/klu_native/refactor.rsklu_refactor.cstep 5
crates/gridoxide-sparse/src/klu_native/solve.rsklu_solve.cstep 4
crates/gridoxide-sparse/src/sparse_klu.rs—the FFI backend, same algorithm via vendored C

crates/gridoxide-sparse/src/klu_native/PROVENANCE.md maps each file to its upstream source and license; the vendored C it was ported from lives in crates/gridoxide-sparse/vendor/suitesparse/.

Reading UCTE-DEF Input

UCTE-DEF is the fixed-column text format the continental-European TSOs used to exchange network snapshots before CGMES. It is superseded, and it is still the format most published remedial-action and capacity-calculation test material is written in — which is the practical reason gridoxide reads it.

#![allow(unused)]
fn main() {
let net = gridoxide::ucte::read("TestCase12Nodes.uct")?;
let report = gridoxide::run_power_flow(
    net.buses.clone(), &net.lines, &net.transformers, &net.shunts,
    PowerFlowOptions::default(),
);
}

Behind the ucte feature. Unlike cgmes it costs nothing to enable: the format is plain text, so the importer is pure std with no dependency, and it is built and tested in CI.

What it brings that the other importers do not

Two things, and they are the reason this importer exists at all rather than a conversion script.

Branch ratings. A ##L record ends in a current limit and a ##T record carries one too:

BBE1AA1  BBE2AA1  1 0 0.0000 10.000 0.000000   5000
//                                             ^^^^ permanent rating, amperes

Nothing in [types::Line] or [types::Transformer] has anywhere to put that — the only rating in the crate before this was the single rate_a in the companion OPF document. It lands in ratings::BranchLimits, indexed by the same flat branch index as everything else:

#![allow(unused)]
fn main() {
let b = net.branch_ids.iter().position(|id| id.starts_with("BBE1AA1  BBE2AA1")).unwrap();
assert_eq!(net.limits[b].patl_a, Some(5000.0));
}

Tap tables. A ##R record is a tap changer:

BBE2AA1  BBE3AA1  1                    -0.68 90.00 16  0        SYMM
//                                     ^^^^^ ^^^^^ ^^ ^^        ^^^^
//                                     du%   theta  n  n'       kind

so a phase shifter arrives as a TapChanger with all 33 positions, not as one complex number:

#![allow(unused)]
fn main() {
let pst = net.tap_changers[0].as_ref().unwrap();
assert_eq!((pst.low, pst.high(), pst.position), (-16, 16, 0));
pst.angle_deg(16);                    // the shift at the top tap
}

Conventions, and why each is worth stating

Every one of these is silent when wrong — the file still loads and the network still solves, just to a different answer.

Generation is negative. UCTE writes generation as a negative number in a field called "active power generation". Net injection is (−generation) − load, and the permissible-generation fields are negated the same way, which swaps which of each pair is the lower bound.

Nominal voltage comes from the node code, not from the record. Character 7 of the 8-character code is a voltage class — 1 is 380 kV, 2 is 220 kV. The voltage field in the record is the set-point a regulating node holds, commonly 400 kV on a 380 kV node. That is a per-unit target of 1.0526, not a different base.

Transformers are reversed relative to the file. UCTE refers a transformer's impedance to its node-1 side and puts the tap changer on node 2. gridoxide's network::branch_calc_param — the MATPOWER convention — wants the series admittance at to and the complex ratio at from. So from is UCTE node 2 and to is node 1, which is the same swap powsybl's own importer makes. Getting it wrong is invisible on a 400/400 phase shifter and a 3% error on a 400/225 unit.

Files are Latin-1 and columns are bytes. One accented character in a node name would shift every later field if the line were decoded as UTF-8 first.

The slack is chosen, not read. UCTE has a node type for it — 3, "U and θ constant" — and none of the vendored fixtures uses it, because the tools that wrote them let the load flow pick. gridoxide picks the largest generator, preferring one that regulates voltage, ties broken on the node code so two imports of one file agree. The choice is recorded in notes, and SlackPolicy::Node overrides it.

What the importer decides, it says out loud

UcteImport::notes carries everything that was a decision rather than a reading, and out_of_service names every branch left out:

note: 8 X-node(s) kept as ordinary buses; boundary-line semantics are not modelled
note: 2 closed busbar coupler(s) imported as near-zero-impedance branches
note: 3 branch(es) omitted as out of operation
note: no `U and theta constant` node in file; slack chosen as `BBE2AA1 ` (largest generation)

A silently dropped element is how an importer produces a plausible wrong answer, so nothing is dropped silently.

How far it agrees with pypowsybl

Every fixture is solved and compared against pypowsybl, element by element (tests/ucte_test.rs). On networks whose transformers declare no magnetizing admittance the agreement is exact to solver tolerance — below 1e-9 on per-unit voltage, 1e-7 degrees on angle, and 1e-5 MW on every branch terminal — including a case with 400/225 transformers, X-nodes, and a phase shifter parked at tap 16 of 16.

Where a transformer does declare a magnetizing admittance, angles differ by around 2e-4 degrees and reactive flows by a few tenths of a MVar. That is one known model difference and nothing else: IIDM places the whole shunt on side 2, while network::branch_calc_param splits it equally between both ends — a Γ model against a π model. Zeroing just those fields drops every figure back to 2e-9 degrees and 1.1e-7 MW, which is how the cause was established rather than assumed. The choice is the crate's, not this importer's: the CGMES and PGM paths split the shunt the same way.

Not modelled

  • X-nodes are ordinary buses. powsybl merges the two half-lines meeting at an X-node into one tie line; gridoxide keeps the X-node. On the buses the two have in common the answers agree, but gridoxide reports more buses and more branches, and an X-node carrying its own load or generation is not treated as a boundary injection.
  • Out-of-service branches are omitted, not carried as openable elements. They are listed in out_of_service so a later phase can reconnect them.
  • ##TT, ##E and any other block are skipped with a note. None appears in any vendored fixture.

Reading IIDM Input

IIDM is the native serialization of powsybl-core's network model — .xiidm files — and the second of the two formats published remedial-action test material comes in, the other being UCTE-DEF.

#![allow(unused)]
fn main() {
let net = gridoxide::iidm::read("nordic32.xiidm")?;
let report = gridoxide::run_power_flow(
    net.buses.clone(), &net.lines, &net.transformers, &net.shunts,
    PowerFlowOptions::default(),
);
}

Behind the iidm feature. It costs one crate (quick-xml, already in the tree as a transitive dependency of the CGMES decoder) and no system library, so like ucte and opf it is built and tested in CI.

Version tolerance is the design constraint

The IIDM fixtures available for testing span eleven schema versions, 1_0 through 1_16, and pypowsybl 1.16 writes 1_17. The model moved underneath them. Limits are the clearest case:

<!-- older -->                     <!-- newer -->
<iidm:currentLimits1               <iidm:operationalLimitsGroup1 id="DEFAULT">
    permanentLimit="721.7"/>           <iidm:currentLimits permanentLimit="5000.0"/>
                                   </iidm:operationalLimitsGroup1>

and danglingLine became boundaryLine along the way.

So the importer accepts any 1_* namespace, understands both spellings, and skips elements it does not recognise rather than failing. A parser pinned to one version reads a fifth of the available material and breaks against the next powsybl release.

What it does not do is skip silently. Every unrecognised element type is counted and named:

note: skipped 4 `hvdcLine` element(s)
note: skipped 2 `slackTerminal` element(s)
note: 1 branch(es) omitted as disconnected

One topology, two spellings

IIDM voltage levels come in two kinds, and this importer folds them into one representation. A busBreakerTopology names its buses; a nodeBreakerTopology numbers its nodes and connects them with switches. Both become nodes of a single NodeBreakerTopology, and the bus view is whatever [bus_view] makes of it — for a bus-breaker file with no switches, exactly the buses the file declared; for a node-breaker file, the connected components of its closed switches.

That is not merely tidy. It is why a topological remedial action will be expressible on an IIDM network at all: the switches survive import as first-class objects instead of being resolved away, so RetentionPolicy picks the view rather than the parser fixing it.

#![allow(unused)]
fn main() {
let options = IidmOptions { retention: RetentionPolicy::RetainAll, ..Default::default() };
let net = gridoxide::iidm::read_with(path, &options)?;
net.topology.switches.len();     // 17 on the node-breaker fixture
net.view.n_buses();              // more than under MergeAll — the switches now split buses
}

Boundary nodes

A dangling (boundary) line runs from a real bus to a boundary node — what UCTE calls an X-node. gridoxide keeps that boundary as a real bus rather than collapsing the half-line into an injection, for two reasons: it is what the UCTE importer does with an X-node, so the two agree structurally on the same network; and it makes tie lines fall out for free. The two halves of a tie line share a pairingKey, so they land on the same boundary bus with no pairing logic at all — which covers both the newer form (a tieLine naming two danglingLines) and the older one (both halves inline as _1/_2 attributes).

A boundary node belongs to no declared voltage level and so has no nominalV. It adopts the voltage of whatever it attaches to; without that the per-unit base would default to 1 V and every quantity through the boundary would be nonsense.

How far it agrees

Two gates, and the first is the stronger.

Cross-format. Three fixtures are pypowsybl exports of .uct files the UCTE suite already checks against pypowsybl. gridoxide reads the same network through two parsers sharing no code and the answers must agree. On the twelve-node case, with and without the phase shifter at tap 16 of 16, they are bit-identical — zero deviation across all 16 branches. On the case with 400/225 transformers and X-nodes the worst deviation is 6e-12 MW, once the seven boundary half-lines whose terminals the two formats declare in opposite order are accounted for.

That last point is a labelling difference, not a physical one: a UCTE ##L record may name the X-node first where the IIDM boundary line names the real bus first, which flips the reported sign of a flow without changing anything.

Against pypowsybl, on fixtures written as IIDM in the first place — which is what covers node-breaker topology, temporary limits and the older spellings, none of which UCTE can express. nordic32 (52 buses, 80 branches) agrees to 1.2e-3 MVar on quantities of several hundred MVar. The residual is the same known model difference the UCTE page quantifies: IIDM places a transformer's magnetizing shunt entirely on side 2 while network::branch_calc_param splits it equally between both ends.

Not modelled

  • HVDC. hvdcLine, vscConverterStation and lccConverterStation are counted and then skipped with a note. gridoxide has a real DC-side network (crates/gridoxide-core/src/dc.rs) reachable from CGMES, and wiring IIDM into it is its own piece of work — one no fixture in the tree calls for, since none contains an HVDC element.

    This page used to say the same thing while the importer did something else: a vscConverterStation shared the generator arm, and since a converter station states no targetP it became a generator producing zero, silently losing the transfer. Counting it is what the page claimed and what it now does.

  • Extensions. slackTerminal, referenceTerminal, mergedXnode, busbarSectionPosition, virtual hubs — all named in notes and skipped. Notably slackTerminal is where powsybl records a chosen slack, so gridoxide currently picks its own (largest generation, preferring a voltage-regulating bus, ties broken on the bus label) and says so in notes.

  • A generator's reactive capability as a curve. reactiveCapabilityCurve states minQ/maxQ as a function of P; types::Bus carries one pair. The importer takes the curve's envelope and reports how many generators it did that for. It is an outer approximation — never forbidding an output the machine can produce, possibly permitting one it cannot at its dispatched P — and it replaces no limit at all, which was the same kind of approximation and a worse one. A minMaxReactiveLimits pair is read exactly.

  • Asymmetric line shunts. IIDM states g1/b1 and g2/b2 separately; Line carries one total that the π-model splits equally. Every line in every vendored fixture is symmetric, and any that is not is counted in notes rather than silently averaged.

Individual generators and loads stay addressable

The importer folds every injection into its bus's net p_spec/q_spec, which is all a power flow needs and is not reversible afterwards. IidmImport::injections keeps the correspondence back: each generator and load by its own IIDM id, the bus it sits on, and the injection the file states.

That is what lets something address an individual machine rather than a bus. A Dynawo dynamic model attaches to a generator by staticId, and a dynamic study needs each machine's own terminal power rather than its bus's total — see Reading Dynamic Data.

Reading MATPOWER Input

MATPOWER's case format is the one the standard transmission test cases are published in: the IEEE cases, PEGASE, RTE, PGLib. A case file is a MATLAB function that assigns literal matrices to mpc.baseMVA, mpc.bus, mpc.gen and mpc.branch, and those four are what src/matpower.rs reads.

#![allow(unused)]
fn main() {
let net = gridoxide::matpower::read("case118.m")?;
let report = gridoxide::run_power_flow(
    net.buses, &net.lines, &net.transformers, &net.shunts,
    gridoxide::TapData::none(),
    PowerFlowOptions { init: PowerFlowInit::Flat, tol: 1e-8, ..Default::default() },
);
}
model = gridoxide.PowerFlowModel.from_matpower("case118.m", tol=1e-8)
model.solve(init="flat")
voltages = dict(zip(model.bus_numbers(), model.voltage_mag()))

From the command line, gridoxide solve case118.m. Behind the matpower feature, which has no dependency; the Python bindings always include it.

The problem it solves is MATPOWER's

The import follows runpf's rules, from MATPOWER's bustypes, ext2int and makeSbus/makeYbus, so that a case means here what it means there:

What the case saysWhat is solved
Bus type 3 or 2 with an online generatorReference or PV bus
Bus type 3 or 2 without onePQ bus; if no reference remains, the first PV bus becomes it
A generator on a PQ busA fixed Pg + jQg injection
The voltage a regulated bus holdsIts generators' Vg (the last online one), never the bus's Vm, which MATPOWER reads only as a starting value
The reference angleThe reference bus's Va
Bus type 4 (isolated)Left out with its branches and generators; reported at zero voltage
A branch with a ratio or a phase shiftA two-winding transformer: the π-model with the complex ratio τ·e^{jθ} on the from side, exactly network::branch_calc_param's model
Gs, BsA shunt admittance, per unit

Everything is per unit on the case's own baseMVA, so a mismatch tolerance is the same quantity MATPOWER measures. A PV bus also carries the summed Qmin and Qmax of its generators, used only if reactive limits are asked for.

Why not the PGM conversion

gridoxide.matpower (the Python package's converter) turns a case into PGM JSON, and that cannot carry a MATPOWER case exactly. PGM has no slack bus, only a source behind a short-circuit impedance, so the reference bus sits behind an impedance the case does not have and drifts off its setpoint by however much current it carries. PGM's transformer clock also holds a phase shift only in multiples of 30°, so the PEGASE and RTE phase shifters are rounded. Read directly, neither loss happens.

What it refuses

A case file is MATLAB code, and some cases finish with code that edits the matrices they just wrote: case33bw converts its loads from kW and its impedances from ohms that way. Reading the literals and skipping the code would solve a network 1000 times more heavily loaded, without a word. So an assignment to mpc.bus, mpc.gen, mpc.branch or mpc.baseMVA that is not a literal matrix is an error, and so is a version-1 case or a binary .mat file. benchmark-grids keeps plain-data copies of such cases under matpower-plain/.

How it is tested

tests/matpower_test.rs reads the transmission cases from case14 to case9241pegase and six distribution feeders, solves each from a flat start, and substitutes the solution into the power-flow equations rebuilt from the raw matrices with MATPOWER's own formulas. Every case lands below 1e-7 p.u., and flipping the sign of the phase shift in the importer fails case1354pegase by 0.44 p.u.

Reading CGMES Input

cargo build --features cgmes builds src/cgmes.rs, a third network-input path alongside the native JSON format and PGM-JSON, reading CGMES (Common Grid Model Exchange Standard) RDF/XML — the IEC 61970/61968 interchange format ENTSO-E and TSOs use.

It is built on cimoxide — a separate Rust project by the same author — for RDF/XML decoding, via its cimoxide-model crate (see Provenance and Licensing). The feature is opt-in since some users only need JSON input and shouldn't pay for cimoxide's dependency tree or build time.

#![allow(unused)]
fn main() {
use gridoxide::cgmes::{load_profiles, cgmes_to_buses_and_branches};
use gridoxide::network::{build_ybus, stamp_shunts};
use gridoxide::run_power_flow_analysis_from_ybus;

let ds = load_profiles(&[&eq_path, &ssh_path, &tp_path, &sv_path])?;
let (buses, lines, transformers, shunts) = cgmes_to_buses_and_branches(&ds, 100e6)?;
let mut ybus = build_ybus(buses.len(), &lines, &transformers);
stamp_shunts(&mut ybus, &shunts);
let result = run_power_flow_analysis_from_ybus(buses, ybus);
}

What the importer expects

The standard EQ+SSH+TP+SV "solved case" profile bundle:

  • TP is required. TopologicalNode is used directly as gridoxide's Bus, so switch-state topology processing is assumed already resolved upstream. See Ideal Switches and Zero-Impedance Branches for what that resolution involves and how cgmes::merge_closed_switches handles the node-breaker case.
  • SV must carry a populated TopologicalIsland.AngleRefTopologicalNode, used as the slack bus. See Multi-Island Power Flow for how reference buses are picked per island.

What is mapped

Loads — EnergyConsumer, ConformLoad, NonConformLoad, EquivalentInjection, ExternalNetworkInjection, and AsynchronousMachine. The last is converted like a plain load, with both P and Q negated.

Branches — ACLineSegment and SeriesCompensator, including ACLineSegment.gch, real shunt conductance, not just bch's reactive charging.

Transformers — 2- and 3-winding PowerTransformers, with RatioTapChanger (including its optional RatioTapChangerTable per-step override, falling back to the linear stepVoltageIncrement formula when absent) and all four PhaseTapChanger variants: Linear, Symmetrical, Asymmetrical, and Tabular.

Shunts — LinearShuntCompensator and NonlinearShuntCompensator.

Voltage-controlled buses — SynchronousMachine plus RegulatingControl, and the same mechanism for StaticVarCompensator and ExternalNetworkInjection, minus the active-power term for the former.

Validation

Validated end-to-end against four ENTSO-E conformance cases, with fixtures referenced via a git submodule (see tests/data/cgmes/README.md):

CaseTestNotes
MicroGrid-BE-MAStests/cgmes_microgrid_be_test.rs
MiniGridtests/cgmes_minigrid_test.rsFirst fixture with more than one 3-winding transformer, which exposed and fixed a real star-bus-indexing bug; also real AsynchronousMachine loads (~9 MW / ~5 MVAr)
PhaseTapChangerLinear PSTtests/cgmes_pst_phase_tap_changer_linear_test.rsMatches published SV values to ~1e-3
RealGridtests/cgmes_realgrid_test.rsLarge real transmission+distribution model, 6252 buses

MicroGrid-BE-MAS and MiniGrid converge cleanly but match their own published SV voltages only within a few percent. That gap was cross-checked (for MicroGrid-BE-MAS) against pypowsybl's own independent CGMES import and AC load flow on the same case, which shows a comparable deviation from the same published values (scripts/bench/cross_validate_cgmes_microgrid_be.py) — confirming it is inherent to solving a boundary-truncated area file with fixed-injection equivalents, not a correctness bug. One known, documented limitation contributes: types::Line has no tap ratio, so it can't absorb the small nominal-voltage mismatch CGMES explicitly allows at boundary tie points.

FullGrid is an import fixture, not a solve fixture

FullGrid is the richest conformity model in the tree — both TapChangerControl modes, two phase shifters sharing one target, a three-winding transformer, HVDC, a bus held by nine machines — and tests/cgmes_tap_table_test.rs, cgmes_tap_regulation_test.rs, cgmes_voltage_control_test.rs and cgmes_node_breaker_test.rs all use it for exactly those.

It does not converge, and it cannot. Its NonlinearShuntCompensatorPoint._7df4778f declares b = 0.99 S and g = 0.99 S for BE_SHUNT_1 at nomU = 225 kV. On a 100 MVA base, \(z_{base} = 506.25,\Omega\), so \(g = 501.19\) per-unit — a shunt compensator dissipating 50 GW on a network whose entire scheduled generation is 485 MW. The first Newton iteration reports a mismatch of 503 pu, which is that conductance and almost nothing else.

The conversion is arithmetically right; the input is not physical. 0.99 turns up across FullGrid as filler for several unrelated quantities — it is also the SVC's inductiveRating/ capacitiveRating, giving that device a ±511 pu reactive band. These are placeholders exercising a profile's classes, not a modelled network.

Worth separating from a different failure mode: FullGrid's published SvVoltage is consistent with its own EQ/SSH transformer data — scripts/bench/check_cgmes_sv_consistency.py flags zero of its ten two-winding transformers above 5%, where Svedala has one and RealGrid has many. The inconsistency here is between the fixture and physics, not between two of its own profiles.

tests/cgmes_fullgrid_test.rs pins all of this, including the non-convergence itself, so a corrected fixture announces itself. This has cost effort before: scripts/bench/README.md records that network::dc_angle_guess was added specifically to make FullGrid converge, did not, and was removed after it broke case3120sp.

Not built or tested in CI — the same local/manual-verification posture as klu and pardiso.

The per-class pages

The remaining pages in this section each take one CIM class or attribute that needed real modeling work, and follow the same structure: why it matters, the concepts and formulas involved, where it sits in gridoxide today, and how other tools handle it.

One voltage base per galvanic level

An ACLineSegment has no ratio. Its two ends are one conductor at one physical voltage — which means per-unit across it is only meaningful if \(|V| = 1.0\) denotes the same volts at both. That is what a per-unit base is for.

CGMES does not guarantee it. The same physical level is declared 380 kV in Belgium and 400 kV in the Netherlands, 220 and 225 either side of another border, and a tie line between them is one wire carrying two different BaseVoltage.nominalVoltage values on its ends. Per-unitizing that line on one end's base — all a single base can do — leaves its two ends in different per-unit systems, so a flat \(1.0\) profile already contains a 5% step across a wire with nothing in it to make one. The solver pushes reactive power to sustain the step, and the whole area comes out high.

So the importer gives every galvanically-connected group of buses one base before it converts a single branch. The group is the connected component over plain conductors — ACLineSegment and SeriesCompensator — and the base is the nominal the most of its members declare, ties going to the larger.

Moving a base is safe, and moving a branch is not

A per-unit base is a choice, not a measurement. Moving one changes the per-unit numbers and leaves the volts alone, because everything that converts between the two reads u_rated: a reported voltage is \(|V| \cdot u_{rated}\), a voltage setpoint is imported as \(target / u_{rated}\), and — the load-bearing one — transformers self-correct, because transformer_tap already takes the node ratings and computes

\[ k = \frac{u_1 / u_2}{u_{1,rated} / u_{2,rated}} \]

so a moved node base is absorbed by the off-nominal ratio. That is why this runs before any branch is converted rather than after.

The one thing it is not invariant to

That invariance holds wherever u_rated is used as a base. It fails in the one place the importer uses it as a stand-in for the actual operating voltage: the StaticVarCompensator rating, which turns an ohmic reactance into a reactive limit through \(Q \approx V^2 / x\) evaluated at \(|V| = 1.0\). Move the base and \(1.0\) p.u. denotes different volts, so the limit moves with it — by \((400/380)^2 = 10.8%\), or \((225/220)^2 = 4.6%\).

The shunt compensators converted a few lines away look identical and are not affected, which is worth seeing clearly: they convert siemens to per-unit, and \(Q_{pu} = |V|{pu}^2 \cdot B{pu}\) recovers the same physical MVAr on any base. Only pinning \(|V| = 1\) makes the base stand for a voltage.

No vendored fixture exercises it. MicroGrid-Type1's single SVC sits at 225 kV — the base that is kept — so its rating is ±0.1 p.u. either way. A document with an SVC on the other side of such a boundary would see its rating shift, and nothing would flag it.

It adjudicates, and the alternative does not

Harmonizing has to choose which of two declarations to keep. That is a judgement about the document, not arithmetic, and it rests on an inference: an ACLineSegment has no ratio, therefore its two ends are one level, therefore a differing declaration is a naming convention rather than a fact.

For 380/400 and 220/225 that inference is right, and the error figures below say so. Where it would be wrong is a document that is itself wrong — a line written where a transformer belongs. Then harmonizing forces the two ends onto one base and the level difference disappears into a model that looks entirely reasonable, where powsybl's branch-side treatment would carry both declarations through and let the discrepancy reach the answer. Neither is correct, because the input is not; the difference is whether to trust the declaration or the topology.

What this does instead of choosing silently is report: CgmesNetwork::base_harmonization names every nominal it merged and how many buses moved, and examples/base_mismatch_probe.rs prints it. That is weaker than not having to choose — it needs someone to look — but it is what makes a mis-declared nominal noticeable rather than absorbed.

What it was worth

Measured against the fixtures' own published SvVoltage:

fixturelines spanning two basesworst error beforeafter
MicroGrid-Type15 of 134.4%1.3%
FullGrid5 of 13— (does not converge)—
SmallGrid, MiniGrid, RealGrid, Svedala0unchanged, bit for bit

On MicroGrid-Type1 the median error falls from 0.96% to 0.01%. With remote voltage control also enabled — the same fixture needs it — the worst error reaches 0.09%, and the machine's own reactive output comes within 2% of the published one. Neither fix is visible underneath the other: the base defect was larger than the reactive power remote control moves, which is why that fixture could not referee remote control until this landed.

What powsybl does

The same problem, solved in the branch rather than the bus, and enough of a real modelling question there to be a configuration option. LinePerUnitMode is either IMPEDANCE — per-unitize on the geometric mean \(n_1 n_2 / S_B\) and fold correction terms into the two end shunts — or RATIO, an ideal transformer of \(n_1/n_2\) on an otherwise ordinary line (LfBranchImpl.createLine).

Both need a branch model with per-terminal shunts or a ratio. types::Line has neither, deliberately: its shunt is one total split equally, which is what every line in every other fixture needs. Doing this in the bus base reaches the same coherent per-unit system without giving every line in the crate a field that ten lines in two fixtures would ever use — and, unlike RATIO, without moving a line into the transformer list and shifting the flat branch index that ratings, terminal_branch and the RAO all address branches by.

Node-Breaker Topology

A CGMES model does not describe a network as buses joined by lines. It describes it the way the substation is actually built: equipment terminates on connectivity nodes, and connectivity nodes are joined by switching devices — breakers, disconnectors, load-break switches, fuses. The buses a power flow needs are a derived view, obtained by fusing every group of nodes reachable through closed switches.

gridoxide has always performed that fusion — see Reading CGMES Input and cgmes::merge_closed_switches. What it did not do until recently was keep the switches. They were consumed at import and discarded, which meant switching state was frozen at the moment the file was read: no switch could be operated between solves, no flow through a breaker could be reported, and no measurement could attach to one.

The node-breaker path keeps them.

Two views of the same file

#![allow(unused)]
fn main() {
use gridoxide::cgmes::{cgmes_node_breaker_to_buses_and_branches, cgmes_to_buses_and_branches};
use gridoxide::switches::SwitchTreatment;
use gridoxide::topology::RetentionPolicy;

// The bus-branch view: switches are fused away. This is the original path.
let (buses, lines, transformers, shunts) = cgmes_to_buses_and_branches(&ds, 100e6)?;

// The node-breaker view: switches survive as network elements.
let net = cgmes_node_breaker_to_buses_and_branches(
    &ds,
    100e6,
    &RetentionPolicy::RetainAdjacentToBusbar,
    SwitchTreatment::Regularize,
)?;
}

net is a switches::NodeBreakerNetwork: the same buses/lines/transformers/shunts any other path produces, plus the BusView they were derived from and the switch↔branch mapping.

Both read the same profiles. The difference is what happens to the switching devices, and that is governed entirely by the retention policy.

Retention policies

A RetentionPolicy answers one question per switch: does this device survive into the solved network as an element of its own, or is it fused away?

PolicyKeepsTypical use
MergeAllnothingReproduces the classical bus-branch view exactly. The baseline.
RetainAdjacentToBusbarswitches with a BusbarSection on at least one sideBusbar coupling and bay isolation — the switching that actually reconfigures a station. The default.
RetainKinds(set)switches of the listed SwitchKinds"Model the breakers, merge the disconnectors."
Explicit(set)the switches you nameStudying a specific bay.
RetainAlleverythingThe full node-breaker network, one bus per connectivity node.

The cost is bus count, and it is not small. On the conformance configurations:

ModelConnectivity nodesMergeAllRetainAdjacentToBusbarRetainAll
MiniGrid10315 buses, 0 switches45 buses, 30 switches105 buses, 90 switches
SmallGrid1,369167 buses, 0 switches540 buses, 373 switches1,369 buses, 1,266 switches
Svedala1,179228 buses, 0 switches638 buses, 857 switches1,179 buses, 1,464 switches

(Bus counts exceed the merged-node count slightly — MiniGrid's 105 against 103 connectivity nodes — because equipment conversion adds buses of its own, notably the star point of a three-winding transformer.)

All of these converge, and in the same iteration count as their own bus-branch solve: 2 for MiniGrid, 5 for SmallGrid, 6 for Svedala, whether every switch is merged or every switch is retained. That is worth stating plainly, because it was not the expected outcome — the working assumption was that retaining switches at this scale would wreck the conditioning of the Jacobian. It does not. See Ideal Switches and Zero-Impedance Branches for why, and for what the alternative formulations would have cost.

How a retained switch is represented

Two treatments, selected by SwitchTreatment.

Regularize is the default and the one everything else in this chapter assumes. Constrain enforces the switch exactly instead — see below.

A retained switch is stamped as an ordinary branch with a small series reactance — the regularization approach. Two consequences follow, and both are the reason this approach was chosen over merging:

  • A switch is a branch. Nothing downstream needs to know it is a switch. It has a flow, so switch_flow is just a branch flow. It has a PTDF/LODF column, so a switch's lodf_column is its bus-split distribution factor. A switching campaign is an ordinary BatchSolver::solve_contingencies sweep. None of that needed new solver code.
  • Operating a switch does not move the sparsity pattern. A switch's position is carried as its terminal status, not as a change to the node set. Opening one changes Y-bus values while the structural entries stay in place, which is what lets PersistentSolver keep its symbolic factorization across states. Merging cannot do this — closing a switch would change n.

The series reactance is switches::switch_reactance(). It is small enough that the voltage drop across a closed switch is negligible and large enough not to dominate the matrix; the same value is used by the ideal-connection handling described in the zero-impedance chapter.

Degenerate switches

A retained switch whose two nodes land in the same bus anyway — because some other closed path already joins them — is degenerate: stamping it would create a self-loop. bus_view detects these and switches::degenerate_switches reports them. They are retained in the topology (so they keep their identity) but not stamped as branches. None of the three models above has any — retained and stamped counts match exactly — but a model with a closed ring inside one bay would.

Operating a switch

#![allow(unused)]
fn main() {
for (switch, branch) in net.switch_branches() {
    println!("{} -> branch {branch}", net.switch_label(switch));
}

net.set_switch_open(switch, true);            // false to close it again
assert_eq!(net.is_switch_open(switch), Some(true));

// Re-solve, then read the flow through the switch itself.
let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut ybus, &net.shunts);
let report = run_power_flow_analysis_from_ybus(net.buses.clone(), ybus);
let v = gridoxide::branch_flow::bus_voltages(&report.buses);
let (p, q) = net.switch_flow(switch, &v).unwrap();
}

set_switch_open mutates the stamped branch's status, which is the single source of truth — is_switch_open reads it back from there rather than from the imported model, so a listing never shows a stale position.

From Python

See Python Bindings:

model = gridoxide.PowerFlowModel.from_cgmes(
    paths, topology="node_breaker", retain="busbar_adjacent"
)
for switch_id, mrid, kind, bus_from, bus_to, is_open, branch in model.switches():
    ...
model.set_switch(switch_id, True)

Constrain: the exact alternative

SwitchTreatment::Constrain puts no admittance in the matrix at all. A closed switch's flow becomes an unknown and the physics becomes two exact equations, \(θ_i = θ_j\) and \(V_i = V_j\) — approach 3 of Ideal Switches and Zero-Impedance Branches. The network it produces carries no switch branches, so it is solved by constrained::solve_constrained rather than by the ordinary Newton loop:

#![allow(unused)]
fn main() {
let net = cgmes_node_breaker_to_buses_and_branches(
    &ds, 100e6, &policy, SwitchTreatment::Constrain,
)?;
let solution = constrained::solve_constrained(
    &mut buses, &ybus, &net.constrained_switches(), 1e-6, 30,
);
for (n, (p, q)) in solution.flows.iter().enumerate() { … }
}

It is not the faster or the more robust of the two — both converge in identical iteration counts on every configuration in the tree, including Svedala with all 1,464 switches constrained. It gives one thing Regularize cannot: an honest answer where the flow is genuinely undetermined. A loop of closed switches has a free parameter in it — the network does not decide how a bus coupler splits current with a parallel one — and solution.indeterminate names those switches instead of reporting the split that the chosen stiffness happens to produce.

State estimation

#![allow(unused)]
fn main() {
let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut ybus, &net.shunts);
let se = SeNetwork::from_node_breaker(&net, ybus.finish());

let mut buses = net.buses.clone();
se::nr::linear_start(&mut buses, &se, &measurements);
let report = se::nr::estimate(&measurements, &mut buses, &se, &SeOptions::default());
}

The measurement model needs no changes at all: a sensor on a breaker is a Target::BranchTerminal at branch_of_switch(switch), and the estimator never learns that any of its branches is a switch — the same reason DC flows, sensitivities and contingencies work on switches unchanged.

What is different is the constraint set, and it inverts the usual proportions. A bus-branch model has a handful of zero-injection buses; a node-breaker model is mostly zero-injection buses, since every internal node of a bay carries switches and nothing else. MiniGrid goes from 7 constrained buses to 37. That is exact, free information — no sensor, no noise, no weight — and it is why the finer topology is observable without any more sensors than the coarse one needs. gridoxide has carried zero-injection buses as hard equality constraints (the KKT augmented system) all along; see The State Estimation Problem.

The flags come from the model's structure, not from the snapshot's numbers. A load whose SSH p and q happen to be zero this hour is not a bus that injects nothing, and a hard constraint saying otherwise would bias every estimate around it.

Two things are worth knowing before running one:

  • Start with linear_start, not flat_start. CGMES marks a de-energized bus by leaving it out of every TopologicalIsland, and such a bus must start at zero. Nothing measures it, so it is pinned wherever the start left it; pinned at 1 p.u. when the truth is 0, it poisons every measurement that touches it. On MiniGrid a flat start diverges to an objective of 2.2e4 where linear_start converges in three iterations to 4e-10.
  • De-energized buses are not an observability gap. observability::analyze reports their unknowns separately, in de_energized, and excludes them from the rank test — they are determined by the network at exactly zero rather than by any sensor. Counting them would report every model with one switched-out node as unobservable.

From the command line

gridoxide switches <profile.xml>... [--retain none|busbar_adjacent|all]
                   [--open <mrid>] [--solve]
$ gridoxide switches MiniGrid_{EQBD,EQ,SSH,TP,SV}.xml --solve
45 bus(es) from 103 connectivity node(s); retain = busbar_adjacent
90 switch(es) in the model, 30 retained, 30 stamped as branches
power flow: Converged in 2 iteration(s)

mrid                                   kind          bus  bus  state branch    P (MW)
_3a783d1d-…                            Disconnector    8   22 closed     18    -9.000
…

Switches are named by mRID, which is what an operator and the source file have in common; --open takes one. The requested profiles must include TP and SV even without --solve, because converting the equipment needs the angle reference from TopologicalIsland.AngleRefTopologicalNode — EQ and SSH alone give the topology but not a slack, and gridoxide will not invent one.

What is not there yet

  • Measurements are not read from CGMES. The estimator accepts a node-breaker network (below), but the profiles carry nothing to feed it: only the OP profile holds Analog/AnalogValue, only FullGrid ships one in the conformance set, and it holds four of them. A caller supplies its own measurements.
  • FullGrid does not solve on either path. This is pre-existing and unrelated to switches.

StaticVarCompensator

Motivation

A Static Var Compensator (SVC) is a shunt-connected, power-electronically-controlled reactive power source: instead of a fixed shunt capacitor/reactor bank, it continuously adjusts its own reactive injection to hold the voltage at its connection point near a setpoint, within a capacitive/inductive rating. Electrically it behaves like a voltage-controlled bus for load-flow purposes — the same \(\vert V_k \vert\)-known, \(Q_k\)-unknown PV formulation the Powerflow page already describes for a generator, just without any active-power term.

CGMES represents one as its own StaticVarCompensator class (a RegulatingCondEq, the same base every SynchronousMachine and ExternalNetworkInjection also derive from), carrying:

  • capacitiveRating / inductiveRating — the SVC's reactive range,
  • slope — a droop coefficient for voltage-vs-reactive-power regulation,
  • q (SSH) — a starting/fallback reactive power value,
  • an optional RegulatingControl reference — the same voltage-mode-target mechanism SynchronousMachine uses.

The concepts

1. Ratings are reactances, not powers

capacitiveRating/inductiveRating read like power quantities ("...at maximum capacitive reactive power") but are documented, and universally treated by real tools, as reactance ratings in ohms. They have to be converted to a susceptance, and from there to a reactive-power rating, rather than used directly as a Mvar value:

\[ B = \frac{1}{X_{rating}}, \qquad Q \approx V^2 B \]

The deciding evidence is that powsybl-core's CGMES importer computes exactly 1 / rating to get the susceptance it stores. If capacitiveRating were already a Mvar quantity, taking its reciprocal to get a susceptance would make no dimensional sense. (See "A bug this caught" below for what happens if you believe the doc text instead.)

An absent or zero rating conventionally means unlimited rather than zero — mapped to \(\pm\infty\) (or ±Double.MAX_VALUE), the same "no rating means no limit" convention tap-changer xMin/xMax uses.

1a. The sign convention, which the code relies on and does not check

The reactance argument above settles the unit. It leaves the sign open, and gridoxide's conversion depends on it:

#![allow(unused)]
fn main() {
q_min = z_base / inductiveRating      // wants a negative result: absorbing
q_max = z_base / capacitiveRating     // wants a positive result: generating
}

That is only right if inductiveRating is itself negative. Surveyed across every StaticVarCompensator in the conformity set, it always is:

FixtureinductiveRatingcapacitiveRating
MicroGrid (BaseCase, Type1, Type2)−5062.55062.5
Svedala (×2)−800800
RealGrid−506.25, −675, −337.5202.5, 675, 337.5
FullGrid−0.990.99

So the convention is real and consistent — inductive negative, capacitive positive — and the division reproduces it rather than assuming it. But nothing checks it. The CIM documentation text describes both as ratings "at maximum … reactive power" without a stated sign, and an exporter that wrote inductiveRating positive would give that bus q_min > 0, quite possibly q_min > q_max, and ReactiveLimits would then clamp against an inverted band. That failure is silent: the solve still converges, at the wrong reactive dispatch.

tests/cgmes_voltage_control_test.rs's no_summed_band_is_inverted_or_empty catches it for the fixtures in the tree, which is coverage rather than a guard — the importer itself would still accept such a file.

1b. FullGrid's ratings are filler, and it is worth knowing which fixtures are

Converted, those ratings give sensible devices everywhere but one. MicroGrid's 5062.5 Ω at 225 kV is \(506.25 / 5062.5 = 0.1\) pu, about 10 MVAr. Svedala's 800 Ω at 400 kV is 2.0 pu, about 200 MVAr. RealGrid's are the same order.

FullGrid's 0.99 Ω is \(506.25 / 0.99 = 511\) pu — 51 GVAr from one substation SVC, on a network whose entire scheduled generation is 485 MW. That is the same 0.99 that appears as BE_SHUNT_1's susceptance and conductance and as this SVC's own slope: filler exercising a profile's classes rather than a modelled device. See Reading CGMES Input for why FullGrid is an import fixture and not a solve fixture.

The lesson generalises past this class. A conversion can be dimensionally right and still produce an absurd number, and the absurdity is the fixture's. Checking the magnitude of a converted quantity against what the device plausibly is — not just the algebra of the conversion — is what separates the two, and is how both of FullGrid's filler values were found.

2. Three regulation behaviors, in increasing fidelity

Hard voltage pin. An SVC that is actively regulating in voltage mode is treated exactly like a PV bus: its controlled bus's \(\vert V \vert\) is fixed at the target and its \(Q\) is the free variable, clamped to \([B_{min}V^2,\ B_{max}V^2]\). This is the same mechanism as a generator's voltage control — the SVC contributes no \(P\) term, which is the only structural difference.

Droop (slope). A real SVC doesn't hold voltage exactly; it regulates along a droop characteristic, so that absorbing more reactive power comes with a slightly lower terminal voltage. The linearized form folds directly into the voltage equation as an extra term rather than being a post-hoc correction:

\[ V + \text{slope} \cdot Q_{SVC} = V_{target} \]

Standby / dead-band. An SVC may sit idle as a fixed susceptance while voltage stays inside a dead-band, only entering active regulation when voltage leaves it. Because that decision depends on the solved voltage, it can't be made before the solve — it needs an outer loop that toggles the bus between a fixed-susceptance PQ shunt and an active PV pin between passes, the same architectural pattern the Reactive Power Limits page describes for Q-limit switching, applied here to decide whether to regulate at all rather than how far a limit was exceeded.

A tool that implements only the hard pin still solves the common case correctly; droop and standby refine it.

3. The regulated bus need not be the SVC's own bus

RegulatingControl.Terminal is independent of the equipment's own terminal, so an SVC can regulate a remote bus. Two consequences for anything converting one: the bus whose voltage gets pinned is the control terminal's bus, while the per-unit base for converting the ohm ratings into a Q limit is the SVC's own physical bus's rated voltage. The two differ whenever regulation is remote.

4. Not regulating? Fall back to a fixed injection

An SVC with controlEnabled = false, a disabled RegulatingControl, or no RegulatingControl at all is not a voltage-controlled bus. The fallback is the SSH q value as a plain fixed reactive injection — an ordinary PQ contribution.

Where this fits in gridoxide today

src/cgmes.rs's StaticVarCompensator conversion (added alongside the ACLineSegment.gch fix — see the Shunt Conductance page — after this fixture's own SVC was found to be silently dropped entirely) mirrors SynchronousMachine's existing RegulatingControl-driven PV upgrade:

  • If the SVC has an enabled, voltage-mode RegulatingControl, the controlled bus (concept 3 — possibly remote) is promoted from PQ to PV and pinned to the target voltage: concept 2's hard pin.
  • Otherwise it falls back to concept 4's fixed Q injection from the SSH q value, using the same sign convention SynchronousMachine.q already established empirically (no negation — see that code's own comment for why the doc text alone isn't trustworthy here).

q_min/q_max follow concept 1 — a reactance rating converted to a per-unit reactive-power limit, \(Q \approx V^2 B \approx B_{pu}\) at \(V \approx 1\) pu (the same flat-voltage approximation SynchronousMachine's own min_q/max_q already make), anchored to the SVC's own physical bus's u_rated:

#![allow(unused)]
fn main() {
let z_base = own_bus.map(|b| buses[b].u_rated * buses[b].u_rated / s_base_va);
buses[controlled_bus].q_min = match (sc.inductive_rating, z_base) {
    (Some(x), Some(zb)) if x != 0.0 => zb / x,
    _ => -f64::INFINITY,
};
buses[controlled_bus].q_max = match (sc.capacitive_rating, z_base) {
    (Some(x), Some(zb)) if x != 0.0 => zb / x,
    _ => f64::INFINITY,
};
}

Two deliberate simplifications versus concept 2's fuller model, both consistent with gridoxide's existing scope elsewhere:

  • No droop/slope. StaticVarCompensator.slope is not read at all — every regulating SVC is a hard voltage pin, the same simplification gridoxide already makes for SynchronousMachine.
  • No standby/monitoring mode. A non-regulating SVC falls back to its fixed q injection permanently; there's no outer loop that would later switch it back into regulation if voltage left some dead-band, since gridoxide's plain newton_raphson doesn't run any outer loop for SVCs at all (only for PV→PQ switching, and only when the outerloop::ReactiveLimits loop is configured, which the default solve does not do).

Tool reference

ToolRating storage (§1)Regulation (§2)Remote (§3)
gridoxideohm rating → per-unit Q limit at \(V=1\), anchored to the SVC's own bus (src/cgmes.rs)hard pin only✅
powsybl-corebMin/bMax in siemens, via getB() = 1 / rating in StaticVarCompensatorConversion.java; zero/absent rating → ±Double.MAX_VALUEdata model only: voltageSetpoint, reactivePowerSetpoint, RegulationMode (VOLTAGE/REACTIVE_POWER). No slope field in core IIDM — droop is the optional VoltagePerReactivePowerControl extension (added only if slope >= 0), dead-band the StandbyAutomaton extension✅
powsybl-open-loadflowconsumes getBmin()/getBmax() as ReactiveLimits (LfStaticVarCompensatorImpl)all three: BUS_TARGET_V hard pin by default; droop folded into that same equation by AcEquationSystemCreator.createGeneratorLocalVoltageControlEquation when the extension and solver flag are both present; standby dead-band via the dedicated MonitoringVoltageOuterLoop✅ VoltageControl.controlledBus
VeraGridstepped Bmin/Bmax on its ControllableShuntregulates a control_bus's voltage to Vset✅
pandapowercreate_svc (plus create_tcsc, create_ssc — the broadest FACTS coverage of the tools surveyed)voltage setpoint regulation❌

power-grid-model and lightsim2grid have no SVC concept at all: PGM's only shunt-connected component is Shunt, a fixed admittance, and lightsim2grid's ShuntContainer is a fixed injection stamped straight into the Y-bus diagonal. Both are consistent with their domains — SVCs are a transmission-level device.

Line Shunt Conductance (ACLineSegment.gch)

Motivation

The Powerflow page's Y-bus construction models each line as a π-equivalent: a series impedance plus a shunt admittance split evenly across both ends. Almost every power-flow tool's documentation talks about that shunt term purely in terms of susceptance — line charging capacitance, the reactive effect of a long line's own capacitance to ground — because for the overwhelming majority of real lines the shunt's real part (conductance, representing corona loss or leakage) is negligible or simply zero.

CGMES's ACLineSegment schema doesn't assume that: alongside bch (susceptance) it defines gch (conductance) as a first-class, independent field, "of the entire line section" like every other ACLineSegment electrical attribute. Most real conformance fixtures leave it at zero, but not all — ENTSO-E's own MicroGrid-BE-MAS fixture has two lines (BE-Line_6, BE-Line_2) with non-negligible gch, together worth several MW of real power at nominal voltage.

The concepts

Every tool that models AC lines at all has shunt conductance somewhere in its branch admittance — this isn't a case of some supporting it and others not. What differs is how it's parameterized, and how visible a distinct "conductance" concept is in the data model. Three variants, all carrying the same physical information:

1. A direct conductance field, per line or per end

The most explicit form: a siemens value stored next to the susceptance. Two sub-variants matter for conversion:

  • One value for the whole line — CGMES's own form, gch "of the entire line section". A converter must split it across the π-model's two ends, conventionally evenly: \(g_1 = g_2 = g_{ch}/2\), exactly the way \(b_{ch}\) is already split.
  • One value per end (g1/g2) — strictly more general, since it can represent an asymmetric line whose two ends carry different shunts. A whole-line value maps into it trivially by halving; the reverse direction loses information unless the two ends happen to be equal.

2. One complex per-end shunt admittance

Instead of naming conductance separately, store a single complex number per end, \(h = g + jb\), and stamp it directly into the Y-bus diagonal: \(y_{11} = y_s + h_{or}\). The conductance is the real part — genuinely present and genuinely solved, just never given its own name. A grep for "conductance" in such a codebase finds nothing, which is a naming fact, not a capability one.

3. Derived from capacitance and a loss tangent

A physically-motivated alternative parameterization: store the shunt capacitance \(c_1\) and a dielectric loss tangent \(\tan\delta_1\), and derive the complex shunt from both:

\[ y_{shunt} = \omega c_1 \tan\delta_1 + j,\omega c_1 \]

so \(g = \omega c_1 \tan\delta_1\). Same information, expressed the way a cable datasheet expresses it. Converting into this form from a raw siemens pair means backing out the tangent as \(\tan\delta_1 = g_{ch}/b_{ch}\) rather than mapping the two fields across directly — and a converter whose source format has no loss-tangent equivalent has no way to produce a nonzero conductance at all.

Where this fits in gridoxide today

Before this fix, types::Line had a b_shunt field and nothing else — src/cgmes.rs's ACLineSegment conversion read bch but never gch, even though its own comment already documented gch as one of the fields "of the entire line section" (a stale comment that got ahead of the code, not the other way around). network::build_ybus had no way to stamp a real shunt term even if the conversion had wanted to.

The fix uses concept 1's whole-line form with the even split, since that's the same convention gridoxide's own bch handling already used and types::Line already has half-open-line self-loop folding logic that only needed a second field threaded through it:

#![allow(unused)]
fn main() {
pub struct Line {
    pub from: usize,
    pub to: usize,
    pub r: f64,
    pub x: f64,
    pub b_shunt: f64, // total line charging
    #[serde(default)]
    pub g_shunt: f64, // total shunt conductance (CGMES ACLineSegment.gch; usually 0)
}
}
#![allow(unused)]
fn main() {
// build_ybus: split shunt admittance (conductance + susceptance) equally to both ends of line
let y_shunt_half = Complex::new(ln.g_shunt / 2.0, ln.b_shunt / 2.0);
y.add(ln.from, ln.from, y_line + y_shunt_half);
y.add(ln.to, ln.to, y_line + y_shunt_half);
}

#[serde(default)] keeps every existing native-JSON and PGM-JSON fixture working unchanged. The PGM importer always sets g_shunt: 0.0: PGM's own line schema is concept 3, and PgmLine has no tan1 field to derive a conductance from — the same "no data, so no effect" stance the rest of that converter takes.

Why this mattered more than "a couple of MW out of a large network"

The missing MW didn't just make voltages a little off everywhere — it concentrated almost entirely into one bus's angle, and nowhere else. BE-Line_6/BE-Line_2 feed directly into the substation hosting MicroGrid-BE-MAS's StaticVarCompensator (see the StaticVarCompensator page), a voltage-magnitude-pinned bus. A pinned bus can absorb a reactive-power mismatch by adjusting its own Q injection, but it has no equivalent slack for an active-power one — so the several MW this fix restores had, before the fix, nowhere to go but that one bus's angle. Cross-validated against pypowsybl's own independent CGMES import (scripts/bench/cross_validate_cgmes_microgrid_be.py, with both tools pinned to the same reference bus so their angles are directly comparable): worst angle deviation across the whole fixture dropped from 0.34° to 0.07° once gch was included — a five-fold improvement concentrated almost entirely at that one substation, exactly where the missing real power was actually flowing in.

Tool reference

ToolParameterizationWhere
gridoxide1 — whole-line g_shunt, split evenly in the Y-bus stamptypes::Line::g_shunt, network::build_ybus; read from gch by src/cgmes.rs, always 0 from the PGM importer
powsybl-core1 — per-end g1/g2 alongside b1/b2 (MutableLineCharacteristics.java); CGMES import splits evenly: .setG1(gch / 2).setG2(gch / 2)ACLineSegmentConversion.java → AbstractBranchConversion.convertBranch
powsybl-open-loadflow1 — same per-end getG1()/getG2(), genuinely in the solved equations, not inert metadataAbstractBranchAcFlowEquationTerm (P/Q mismatch terms), AcBranchVector (vectorized evaluator), LfAsymLineAdmittanceMatrix
lightsim2grid2 — one complex per-end shunt h_or/h_ex, stamped as yac_11_ = ys + h_or; no separately named conductance field anywhere in the C++ coreelement_container/LineContainer.hpp, TwoSidesContainer_rxh_A.hpp; its powsybl import builds h_or = g1 + j·b1, confirming round-trip agreement with the model above
power-grid-model3 — c1 + tan1, with \(g = \omega c_1 \tan\delta_1\) feeding the same y1_shunt_/y0_shunt_ termscomponent/line.hpp. A CGMES→PGM converter must compute tan1 = gch / bch

PhaseTapChangerLinear

Motivation

A phase-shifting transformer (PST) uses its tap changer to shift the voltage angle across the transformer, not (only) its magnitude — a way to directly control how much active power flows through a particular path in a meshed network, independent of the voltage-magnitude control an ordinary tap changer provides. CGMES models four distinct ways a phase tap changer's per-step behavior can be specified, each its own concrete class: PhaseTapChangerSymmetrical and PhaseTapChangerAsymmetrical (trigonometric formulas), PhaseTapChangerTabular (an explicit per-step lookup table), and PhaseTapChangerLinear — the simplest of the four.

PhaseTapChangerLinear is exactly what its name says: ratio is always exactly 1.0 (a pure phase shifter, no magnitude change at all), and angle is linear in tap step — \(\alpha = (\text{step} - \text{neutralStep}) \cdot \text{stepPhaseShiftIncrement}\) — a mathematical approximation of a real PST's behavior, per the CIM class's own doc comment, rather than a physically derived model like Symmetrical/Asymmetrical's trigonometric curves. ENTSO-E's conformance suite has two dedicated test configurations for exactly this class, PST_PhaseTapChangerLinear_Type1/_Type2 — without support for it, neither could be solved correctly at all.

The concepts

1. Per-step behavior: formula vs. table, normalized at import

The four CGMES classes are four specifications of the same underlying thing: for a given tap position, what complex ratio \(\rho e^{j\alpha}\) (and what r/x/g/b deviation) does the transformer have? The distinction only needs to exist while reading CGMES. Once each class's own rule is evaluated, everything downstream can work from a uniform flat representation — a per-step table of \({\rho, \alpha, r, x, g, b}\) plus a scalar tap position selecting a row, with the originating class kept only as a property for round-trip export.

For PhaseTapChangerLinear the rule is the two lines from the Motivation: \(\rho = 1\) and \(\alpha = (\text{step} - \text{neutralStep}) \cdot \text{stepPhaseShiftIncrement}\), evaluated for every step from lowStep to highStep.

A converter that only ever needs one step's effect — the current SSH position, because it never exports a step table — can evaluate the same rule for that single step and skip building the table at all.

2. Reactance varies with tap position

A phase shifter's leakage reactance is not constant across its tap range. When xMin/xMax are present, \(x\) is interpolated between them by a trigonometric rule shared between the Linear and Symmetrical cases:

\[ x(\alpha) = x_{min} + (x_{max} - x_{min}) \left( \frac{\sin(\alpha/2)}{\sin(\alpha_{max}/2)} \right)^{2} \]

where \(\alpha_{max}\) is the largest angle reachable across the changer's own step range. This value supersedes the PowerTransformerEnd.x from the EQ profile — the reason phase tap changers need an x-override path at all, where an ordinary ratio tap changer usually doesn't.

3. Who moves the tap: fixed input vs. control outer loop

Regardless of which CGMES class produced it, the phase-shift angle is a fixed parameter within any single Newton-Raphson solve — it is not an unknown, and appears in the Jacobian only if a tool wants sensitivities with respect to it. Two designs then exist for choosing that parameter's value:

  • Fixed at conversion. The tap position from the input snapshot (CGMES SSH step) is baked in once before the solve, and only an explicit external call changes it between solves.
  • Moved by an outer loop. Solve, check whether the controlled branch's active-power or current flow matches its target, adjust the tap position, re-solve — the same architectural pattern the Reactive Power Limits page describes for Q-limit switching. Since tap positions are discrete, an "incremental" refinement computes a continuous \(dP/d\alpha_1\) sensitivity from the Jacobian to estimate how many positions to move at once, bounded to prevent oscillation.

This choice is orthogonal to concepts 1 and 2: a tool can model all four CGMES flavors faithfully and still never move a tap, or move taps aggressively while supporting only one flavor.

Where this fits in gridoxide today

gridoxide takes concept 3's fixed-input option: tap position is chosen once at conversion time from the current SSH step, and there's no outer loop anywhere in the solver that moves a tap to hit an active-power or current target. Every tap changer conversion (RatioTapChanger, all four PhaseTapChanger variants) computes the complex ratio/angle for whatever step CGMES's SSH profile already says it's at and bakes that into the transformer's tap field before Newton-Raphson ever runs.

phase_tap_linear (src/cgmes.rs) is concepts 1 and 2 for the single current step — no step table is built, since nothing in gridoxide exports one:

#![allow(unused)]
fn main() {
fn phase_tap_linear(ptc: &Element, mrid: &str, xtx: f64, at: Option<f64>) -> Result<TapEffect, CgmesError> {
    ...
    let angle_rad_at = |s: f64| -> f64 { ((s - neutral) * inc_deg).to_radians() };

    let alpha = angle_rad_at(step);
    let tap = Complex::from_polar(1.0, alpha);

    let alpha_max = (low..=high).map(|s| angle_rad_at(s as f64)).fold(f64::MIN, f64::max);
    let x_min = ptc.get_f64("PhaseTapChangerLinear.xMin");
    let x_max = ptc.get_f64("PhaseTapChangerLinear.xMax");
    let x_override = match (x_min_max(x_min, x_max, xtx), alpha_max != 0.0) {
        (Some((x_min, x_max)), true) => {
            let ratio = (alpha / 2.0).sin() / (alpha_max / 2.0).sin();
            Some(x_min + (x_max - x_min) * ratio * ratio)
        }
        (Some(_), false) => Some(0.0),
        (None, _) => None,
    };

    Ok(TapEffect { tap, x_override })
}
}

The x-interpolation helper is shared with phase_tap_symmetrical, matching concept 2's "one rule for both classes" — generalized to take raw xMin/xMax rather than a PhaseTapChangerNonLinear reference, since PhaseTapChangerLinear is a structurally distinct, shallower CGMES class carrying its own same-named fields, not a sibling subtype of Symmetrical/Asymmetrical.

Tool reference

ToolPer-step model (§1)x interpolation (§2)Tap movement (§3)
gridoxideall four CGMES flavors, evaluated for the current SSH step only — no step table✅ shared Linear/Symmetrical helper (phase_tap_linear, phase_tap_symmetrical)fixed at conversion; no outer loop
powsybl-coreall four, normalized at import into one flat PhaseTapChangerStep table ({rho, alpha, r, x, g, b}); CgmesPhaseTapChangerBuilder.addSteps() dispatches on isLinear()/isTabular()/isAsymmetrical()/isSymmetrical(), originating class kept only as a property for SSH round-trip✅ getStepXforLinearAndSymmetrical, shared by addStepsLinear() and addStepsSymmetrical()data model only — carries a tap position, doesn't move it
powsybl-open-loadflowconsumes the normalized table; nothing downstream distinguishes a Linear-derived changer from any other (regulation mode is CURRENT_LIMITER vs ACTIVE_POWER_CONTROL, not the CGMES class)inherited from the imported table✅ PhaseControlOuterLoop / AcIncrementalPhaseControlOuterLoop, the latter using a \(dP/d\alpha_1\) sensitivity to size discrete moves
lightsim2grida single fixed per-transformer angle shift_ (radians) alongside the magnitude ratio_ — structurally a fixed ratio+angle tap, no per-step flavors❌fixed input; changeable only via GridModel::change_shift_trafo(...) between solves. Its pandapower import rejects "ideal phase shifter" transformers outright (RuntimeError("Ideal phase shifters are not modeled..."))
power-grid-modeln/a — no CGMES importn/a✅ TapChangingStrategy outer loop
VeraGridnot surveyed (has a CIM importer)not surveyed✅ control_taps_phase
pandapowernot surveyed (has a CIM importer)not surveyed✅ control.DiscreteTapControl/ContinuousTapControl

RatioTapChanger.RatioTapChangerTable

Motivation

An ordinary (voltage-magnitude-only, non-phase-shifting) tap changer's simplest CGMES representation is RatioTapChanger's own stepVoltageIncrement — a single percentage, giving a ratio linear in tap step: \(\text{ratio} = 1 + (\text{step} - \text{neutralStep}) \cdot \text{stepVoltageIncrement}/100\). Real transformers don't always tap this uniformly, though — non-uniform winding turns per step, or step- dependent leakage reactance, are both real physical effects a single linear coefficient can't capture. CGMES accommodates this with an optional RatioTapChanger.RatioTapChangerTable reference: a plain RatioTapChanger may carry this alongside its own stepVoltageIncrement, pointing to a table of RatioTapChangerTablePoint rows giving each step's ratio (and optionally r/x/g/b deviation) explicitly, rather than via the linear formula.

ENTSO-E's Svedala conformance fixture (national-scale substation-area model, 53 PowerTransformers) uses this extensively: all 11 of its RatioTapChangers reference a table.

The concepts

1. An optional table alongside a formula — not a separate class

This is architecturally different from PhaseTapChangerTabular, and the difference drives everything else on this page. PhaseTapChangerTabular is its own distinct CGMES class: if its table lookup fails there is nothing else to fall back on, so a failed lookup is a genuine data error. RatioTapChangerTable is just an optional reference that a RatioTapChanger may or may not carry — the same object always has stepVoltageIncrement sitting right there.

The consequence is a precedence rule rather than an error path: try the table, fall back to the formula when the reference is absent, the table is empty, or the table is invalid. A missing or unusable table is a normal, expected condition, not a failure.

2. What a table point can override

Each RatioTapChangerTablePoint carries ratio plus optional r, x, g, b deviations (in percent) for its step — the same TapChangerTablePoint shape PhaseTapChangerTabular's own points use. A tool can therefore honor anywhere from just the ratio up to the full impedance deviation, which is a scope choice independent of concept 1's precedence rule.

3. Full step table vs. current step only

Whether a converter needs to materialize the whole table depends on what it does downstream. A tool that exports models, or that moves taps during the solve, needs every step. A tool that only ever solves the snapshot it was handed needs exactly one row — the one matching the SSH step — and can look it up directly.

Where this fits in gridoxide today

ratio_tap_table (src/cgmes.rs) implements concept 1's precedence rule, with concept 3's single-step lookup (gridoxide only ever needs this step's effect, since it neither exports step tables nor moves taps — see the PhaseTapChangerLinear page for the latter):

#![allow(unused)]
fn main() {
fn ratio_tap_table(ds: &CimDataset, table_mrid: &str, step: i64, xtx: f64) -> Option<TapEffect> {
    for pt_mrid in by_type(ds, "RatioTapChangerTablePoint") {
        let pt = get(ds, pt_mrid, "RatioTapChangerTablePoint")?;
        let Some(owner) = pt.get_ref("RatioTapChangerTablePoint.RatioTapChangerTable") else { continue };
        if owner != table_mrid || pt.get_i64("TapChangerTablePoint.step") != Some(step) {
            continue;
        }
        let ratio = pt.get_f64("TapChangerTablePoint.ratio").unwrap_or(1.0);
        let x_pct = pt.get_f64("TapChangerTablePoint.x").unwrap_or(0.0);
        return Some(TapEffect { tap: Complex::new(ratio, 0.0), x_override: Some(xtx * (1.0 + x_pct / 100.0)) });
    }
    None
}
}

Returning None (rather than an error) when the table or a matching point for the current step isn't found is the deliberate difference from phase_tap_tabular's own hard-error behavior — exactly concept 1: the caller falls back to the linear stepVoltageIncrement formula instead of treating a missing/invalid table as a data error.

On concept 2, gridoxide reads only ratio and x. r/g/b deviations have no representation in TapEffect at all ({ tap: Complex<f64>, x_override: Option<f64> } — no r_override, no g/b override), a pre-existing simplification across every tap-changer conversion in this file, not something specific to this feature.

Validated against Svedala — but not a strong before/after signal on this particular fixture

Solving ENTSO-E's Svedala conformance case end-to-end (191 buses after this fixture's own 3-winding transformer star-bus synthesis on a different fixture with the same feature) converges cleanly in 6 Newton-Raphson iterations, matching 108 published SvVoltage values with a mean absolute error of ~0.53% and a worst case of ~4.35%.

Disabling the table lookup (forcing every RatioTapChanger back onto its linear fallback, as a direct A/B comparison) barely moves either number — this fixture's own tables happen to have ratios that are exactly linear already (confirmed directly against the raw EQ XML: table step 1 gives ratio 0.88, matching \(1 + (1-13)\times 1/100 = 0.88\) exactly, all the way through step 25). So Svedala doesn't demonstrate a large accuracy win from this feature specifically — its real value is CIM-spec correctness for the (real, if less common) case where a table genuinely diverges from the linear approximation, which this particular fixture just doesn't happen to exercise.

Tool reference

ToolPrecedence (§1)Fields read (§2)Table scope (§3)
gridoxidetable first, linear formula on absent/missing point (ratio_tap_table returns None)ratio, xcurrent SSH step only
powsybl-coretable first, formula on absent/empty/invalid table — CgmesRatioTapChangerBuilder.addSteps() dispatches to addStepsFromTable or addStepsFromLowHighIncrement, with an explicit isTableValid() check betweenratio, r, x, g, b per rowfull step table, materialized for export

Of the tools surveyed, only these two have a CGMES RatioTapChangerTable path at all: power-grid-model and lightsim2grid have no CGMES import, and VeraGrid's and pandapower's CIM importers weren't surveyed for this specific reference.

ExternalNetworkInjection

Motivation

Any CGMES sub-model that covers less than the full interconnected synchronous area — which is to say, almost every real CGMES file, since even a large national TSO's own model is a part of Continental Europe's actual network — needs some way to represent what lies beyond its own boundary. CGMES has (at least) two distinct classes for this: EquivalentInjection and ExternalNetworkInjection. Both carry P/Q; the difference is that ExternalNetworkInjection additionally derives from RegulatingCondEq (the same base StaticVarCompensator and SynchronousMachine share), so it can also carry a RegulatingControl and behave like a voltage-regulated source, not just a fixed injection. ENTSO-E's MiniGrid conformance fixture uses it this way for its two external-grid connection points ("Q1"/"Q2").

The concepts

1. An external injection is a generator, not a new element type

Structurally, "the rest of the interconnected system" behaves exactly like a generator at the boundary bus: it injects P and Q, it may hold a voltage setpoint, and it has (possibly unbounded) reactive limits. Nothing about it needs a distinct element in the internal network model — the same RegulatingControl-driven PV upgrade a SynchronousMachine gets applies unchanged, with the originating CGMES class kept only as a tag for round-trip export.

This applies to EquivalentInjection and ExternalNetworkInjection equally: the two CGMES classes need not map to two internal types.

2. Sign convention: both P and Q are negated

CGMES's SSH power-flow values for these classes are given in the load sign convention — power drawn from the network at the terminal — so converting to an injection means negating both:

\[ P_{inj} = -p_{SSH}, \qquad Q_{inj} = -q_{SSH} \]

This is worth stating explicitly because it is not universal across CGMES's regulating-equipment classes: SynchronousMachine's own q follows a different rule in practice (no negation), so "it derives from RegulatingCondEq, therefore it signs like a machine" is exactly the wrong inference. The right grouping is by conceptual role: an external injection stands in for a network, not for a physical rotating machine.

3. Boundary-point folding applies to only one of the two classes

An EquivalentInjection sitting exactly at a network boundary point can be folded into the virtual generation/load of the boundary line that crosses it, rather than becoming a standalone element. ExternalNetworkInjection has no equivalent path, because it isn't itself a boundary-defining class — it is always a standalone element at an ordinary bus. This is the one real structural difference between the two, and it only matters to a tool that models boundary lines as first-class objects.

4. Under-specified limits interact with slack selection

CGMES often leaves an external injection's active-power limits unspecified, and the natural default is "unbounded" (\(\pm\infty\), or ±Double.MAX_VALUE). Any heuristic that picks a slack bus by generator size then has to cope with a generator whose stated maxP is absurd — the usual treatment is a plausibility threshold above which a candidate is excluded. So an under-specified external injection tends to be filtered out of slack consideration rather than favored, purely as a side effect of the defaulting.

Where this fits in gridoxide today

The conversion (src/cgmes.rs) applies concept 1 by mirroring StaticVarCompensator's block almost exactly — same RegulatingCondEq/RegulatingControl-driven PV-bus upgrade, same fallback to a fixed injection when not actively regulating — with concept 2's sign convention:

#![allow(unused)]
fn main() {
for mrid in by_type(ds, "ExternalNetworkInjection") {
    let eni = own(ds, mrid, "ExternalNetworkInjection")?;
    ...
    buses[bus].p_spec += -eni.get_f64("ExternalNetworkInjection.p").unwrap_or(0.0) * 1e6 / s_base_va;
    buses[bus].q_spec += -eni.get_f64("ExternalNetworkInjection.q").unwrap_or(0.0) * 1e6 / s_base_va;
    ...
}

Both P and Q are negated here — EquivalentInjection's convention, not SynchronousMachine's Q exception (see the StaticVarCompensator page for why that exception exists and why it doesn't extend here).

Concepts 3 and 4 don't arise: gridoxide has no boundary-line object for an injection to fold into, and its slack bus comes from CGMES's own TopologicalIsland.AngleRefTopologicalNode (see Multi-Island Power Flow) rather than from any generator-size heuristic, so an unbounded maxP has nothing to distort.

Tool reference

ToolInternal type (§1)Sign (§2)Boundary folding (§3)
gridoxidebus-level injection; PV upgrade if voltage-regulating, else fixed P/Q (src/cgmes.rs)both negated❌ no boundary-line model
powsybl-coreIIDM Generator (EnergySource.OTHER) for both CGMES classes, distinguished only by a PROPERTY_CGMES_ORIGINAL_CLASS string for round-trip exportboth negated: targetP = -updatedPowerFlow.p(), targetQ = -updatedPowerFlow.q() — same in EquivalentInjectionConversion.update()✅ for EquivalentInjection at a boundary point (folded into a BoundaryLine); n/a for ExternalNetworkInjection
powsybl-open-loadflownone of its own — nothing in the tool references ExternalNetworkInjection or its origin-class property; it is an ordinary generator by the time the solver sees itinheritedinherited

powsybl-open-loadflow's only indirect sensitivity is concept 4: ExternalNetworkInjectionConversion defaults maxP to ±Double.MAX_VALUE, and LargestGeneratorSlackBusSelector filters out generators whose maxP exceeds a plausibility threshold when choosing a slack candidate.

power-grid-model and lightsim2grid have no CGMES import, so neither has an equivalent concept.

Feature comparison: gridoxide vs. reference tools

A survey of five independent power-flow implementations — what each actually supports (verified against source/docs, not assumed), compared against gridoxide's own current scope — used to decide what gridoxide tackles next. Three (lightsim2grid, power-grid-model, powsybl-open-loadflow) are full local checkouts under references/ (itself gitignored, hence this file living at the repo root instead); see each tool's own CLAUDE.md/README for how to consult them further. The other two, VeraGrid (the GridCal successor) and pandapower — both also used as comparison tools in scripts/bench/run_case_suite.py — aren't checked out under references/; they're verified instead by reading their installed packages' own source directly (pip install VeraGridEngine pandapower; see each package's own directory structure for the file paths cited below). This file is a snapshot, not a living document, and will drift as gridoxide and all five tools evolve.

Scope of the most recent revision. Six rows were added for analysis types the table had no row for at all — remedial action optimization, voltage stability, harmonics, reliability, protection coordination, and input-format coverage — and the single "dynamic simulation" row was split into three, because RMS, EMT and small-signal analysis are three different machineries and one checkbox understated the gap. Absence of a row is how a gap goes untracked; that was the point of the exercise.

For the added rows, the three tools checked out under references/ (lightsim2grid, power-grid-model, powsybl-open-loadflow) were searched directly, and each ❌ below names what was searched for. VeraGrid and pandapower could not be re-surveyed — the previous revision read their installed packages and neither is installed here any more — so they are marked not surveyed rather than ❌, per the convention the "Multi-island" row introduced: absence of a survey is not evidence of absence of a feature. Their cells in the three split dynamics rows carry forward the directory names the previous survey recorded, which is evidence rather than inference.

Earlier revisions. gridoxide's own column was re-verified against current source (every cell claiming support names the function or type implementing it), and the "CGMES / CIM import" row was checked across all five comparison tools by counting CGMES/CIM-named files in their installed trees. The other five tools' cells in every row predating this revision were not re-surveyed — treat them as the older snapshot.

Summary table

Featurelightsim2gridpower-grid-modelpowsybl-open-loadflowVeraGridpandapowergridoxide (today)
AC power flow (Newton-Raphson)✅✅✅✅✅✅
DC (Bθ) power flow✅❌✅ (two approximation variants)✅ (SolverType.DC)✅ (rundcpp)✅ linear::btheta::dc_power_flow, both approximation variants
Constant-admittance linear power flow❌✅ (CalculationMethod.linear, + linear_current)❌✅ (SolverType.LACPF)❌✅ linear::impedance::linear_power_flow
Gauss-Seidel✅ (+ "synch" variant)❌❌✅ (SolverType.GAUSS)✅ (algorithm="gs")❌
Fast-decoupled (XB/BX)✅❌✅✅ (SolverType.FASTDECOUPLED — one generic variant, not confirmed as a separate XB/BX split)✅ explicit "fdbx"/"fdxb" split (pypower/fdpf.py)❌
Q-limit enforcement (PV→PQ switching)❌ explicitly disclaimed⚠️ stubbed, "not yet fully implemented"✅ ReactiveLimitsOuterLoop, incl. capability curves✅ PowerFlowOptions.control_q✅ enforce_q_lims (NR algorithm only, per its own docstring)✅ outerloop::ReactiveLimits (opt-in; plain newton_raphson still ignores q_min/q_max), composable with distributed slack and tap control — see Outer Loops
Distributed slack (multi-bus)✅❌✅ + area-interchange control✅ PowerFlowOptions.distributed_slack✅ distributed_slack + per-generator slack_weight✅ outerloop::DistributedSlack with SlackDistribution — per-bus participation weights (uniform over Slack+PV, or explicit, normalized per island so a disconnected network sizes each island's correction by its own generators). Reports the shift per bus, the residual per island, and names any island left on a single slack rather than skipping it silently. Bus types do not change, so PersistentSolver keeps its symbolic factorization across the whole outer loop — unlike the Q-limit loop, which must reset. Area-interchange control too: outerloop::AreaInterchange holds each control area's net export at a scheduled value, with the slack's own area as the dependent one — the system is over-determined by exactly one, because both ends of a tie are measured into the branch and so sum to its losses rather than cancelling. It generalizes distributed slack (one area, zero target, is distributed slack — asserted by running both and comparing shifts) and the two may not both be configured. All three importers supply areas: CGMES from ControlArea/TieFlow with membership derived by cutting the tie branches, IIDM from <iidm:area>'s <voltageLevelRef> directly, UCTE from its ##Z country codes. CGMES and IIDM also state the schedule (netInterchange/interchangeTarget, both negated — both state an import). gridoxide solve --area-interchange. See Distributed Slack and Area Interchange Control
Remote voltage control (controller regulates a different bus)❌❌✅ VoltageControl.controlledBus ≠ controller's own bus✅ control_remote_voltage: controlled bus → PQV mode, controller bus → P mode (Compilers/circuit_to_data.py::set_bus_control_voltage)❌ no built-in equivalent found in control/⚠️ CGMES import only, but no longer static: RegulatingControl.Terminal resolves to the controlled bus, and outerloop::RemoteVoltageControl (--control-remote-voltage) then moves the control onto the machine — the controller bus becomes PV and its setpoint is driven until the controlled bus reaches target, so the reactive power is produced where the machine is and the machine's own capability is what bounds it. Reaches the same fixed point as the exact formulation (fix `
Shared voltage control (several controllers, one controlled bus)❌❌✅ genuine reactive dispatch inside the Newton system: DISTR_Q equations 0 = qPercent_i·Σ_j q_j − q_i, one per controller, so n controllers add n−1 equations alongside the single BUS_TARGET_V. Split keys come from explicit per-generator reactive keys, falling back to Qmax-range-proportional, then uniform (Control::createReactiveKeys); recomputed when a controller is disabled, e.g. by the reactive-limits outer loop⚠️ not really: set_bus_control_voltage tracks bus_voltage_used and logs "Different control voltage set points" on conflict. Its qshare_per_bus is a per-bus dispatch of that bus's own aggregate Q across its own devices, (Q_limited − Qmin)/Qrange — not a cross-bus split among several controllers of one remote bus❌⚠️ capability and attribution yes, in-solver dispatch no. Reactive limits sum across every machine holding one bus (cgmes::VoltageControl), which is what the injections beside them always did — until that was fixed they were overwritten, so a bus held by six machines carried one machine's nameplate. Not rare: 62 of RealGrid's 417 voltage-regulated nodes, 2 of FullGrid's 3. A target two controllers disagree on is reported through VoltageControlReport::target_conflicts rather than silently resolved — no vendored fixture has one, which is why the last writer still wins there and any rule would be untested. crates/gridoxide-core/src/dispatch.rs now attributes each bus's solved reactive power to the individual machines holding it (gridoxide solve --dispatch, PowerFlowModel.machine_dispatch()) — by explicit key, else capability-proportional, else uniform, with clamp-and-redistribute against each machine's own range, checked against RealGrid's own published SvPowerFlow per terminal (all 496 machines; the split rule lands within 7.4% on the 62 shared buses, against a 7.5% solve-vs-published backdrop). It changes no answer: every shared-control case in the vendored corpus is machines at the bus they regulate, which is an ordinary PV bus with one injection, so the split is arithmetic on the result. What is genuinely absent is the in-solver dispatch: powsybl solves the remote case inside the Newton system with n−1 DISTR_Q equations, needed when controllers sit at different buses — a configuration no vendored fixture has, so it is deliberately unbuilt. The attribution also measured a previously invisible simplification: reactive limits bound the bus's net injection while the limits describe the machines' own capability, so on 6 of RealGrid's 416 regulated buses a co-located load saturates the machine before the clamp fires. The tap-side equivalent is built — outerloop sizes several tap controllers on one bus against each other — but taps sit outside the Newton system, which is what makes that the cheaper half
Transformer tap / phase-shifter auto-control❌ (fixed at init only)✅ TapChangingStrategy outer loop✅ several outer loops (voltage, reactive power, phase)✅ control_taps_modules/control_taps_phase options✅ control.DiscreteTapControl/ContinuousTapControl (control/trafo_control.py)✅ outerloop::TransformerVoltageControl and PhaseControl — powsybl's incremental strategy, stepping the discrete position by \(\Delta\rho = (V^{target} - V_c)/(\partial V_c/\partial\rho)\) from ac_sensitivity, with a deadband, an insensitivity filter and a direction-change budget. Several controllers on one bus (or one regulated flow) are sized against each other rather than each against the whole deviation. CGMES supplies both halves — every position of all four PhaseTapChanger flavours plus RatioTapChangerTable, and TapChangerControl in voltage and activePower modes; IIDM and UCTE supply theirs. On Svedala all eleven controllers go from 2–5% outside their deadbands to inside. Not the two continuous-then-round strategies, not reactivePower mode, not shunt-section control, and not inside a contingency or batch sweep — see Transformer Tap Control
3-winding transformers❌ absent✅ (star-equivalent via 2 legs)✅✅ (Devices/transformer3w.py, plus a generic N-winding transformerNw.py)✅ (create_transformer3w)✅ power-grid-model's own star equivalent — three two-winding legs to a synthesized star node, pgm::three_winding_legs assembling them once for the symmetric and phase domains alike. Symmetric against PGM's published output; asymmetric against its own asym_output_batch over ten tap positions, with each leg's winding configuration asserted separately since a balanced reference cannot see it
Switches / node-breaker topology❌ (TODO in source)⚠️ implicit via from_status/to_status, no discrete switch component✅ full node-breaker + NodeBreakerTraverser✅ (Devices/Branches/switch.py; CIM/IIDM importers also read node-breaker topology directly)✅ (create_switch/create_switches — bus-bus, bus-line, bus-trafo; core, not bolted-on, to pandapower's own topology model)✅ topology::NodeBreakerTopology over CGMES ConnectivityNodes and nine switch classes plus Junction, with a per-switch RetentionPolicy (merge all / busbar-adjacent / by kind / explicit / retain all) selecting the view. A retained switch is a first-class element: own mRID, own flow, set_switch_open between solves, own LODF column (= its bus-split factor). Position is carried as terminal status, so switching does not move the sparsity pattern and PersistentSolver keeps its symbolic factorization. Verified on MiniGrid/SmallGrid/Svedala under every policy, up to 1,464 retained switches, at unchanged iteration counts. State estimation consumes the node-breaker view too — a measurement attaches to a retained switch, and the bays' zero-injection constraints are what make them observable (tests/cgmes_node_breaker_se_test.rs, which also asserts the merged view estimates through the same path). See Node-Breaker Topology
HVDC✅ DC lines❌✅ VSC/LCC✅ (hvdc_line.py, vsc.py) + UPFC (upfc.py)✅ create_dcline (lossy point-to-point) + create_vsc/create_vsc_stacked/create_vsc_bipolar✅ crates/gridoxide-core/src/dc.rs: a real DC-side network (DcBus/DcLine, solve_dc_network) with VsConverter/CsConverter converters and converter losses, resolved by cgmes_resolve_dc_converters into AC-side injections. Only reachable via CGMES import — no HVDC element in the PGM-JSON or native-JSON paths
SVC (static var compensator)❌❌✅✅ (ControllableShunt: stepped Bmin/Bmax regulating a control_bus's voltage to Vset)✅ create_svc + create_tcsc (thyristor-controlled series capacitor) + create_ssc (static synchronous compensator) — broadest FACTS-device coverage of the six⚠️ CGMES StaticVarCompensator only: voltage-regulating (pins the controlled bus, incl. remote) when its RegulatingControl is voltage-mode and enabled, else a fixed Q injection. No Bmin/Bmax susceptance limits, no TCSC/SSC
Asymmetric / unbalanced power flow❌ symmetric only✅✅ (LfAsym*)✅ (dedicated Simulations/PowerFlow3ph/ driver)✅ (runpp_3ph)✅ phase domain throughout — three buses per node laid out 3·node + phase, sequence-to-phase transforms for lines, transformers, shunts, links and three-winding transformers. Gated against power-grid-model's own asymmetric fixtures for lines, transformers and three-winding transformers (tests/pgm_asym_*_test.rs)
CGMES / CIM import❌ (0 CGMES/CIM-named files in its tree)❌ (0 CGMES/CIM-named files in its tree)✅ native, the reference implementation here✅ (48 CGMES/CIM-named files)✅ converter/cim (54 CGMES/CIM-named files)✅ EQ/EQBD/SSH/TP/SV profile merge by mRID, node-breaker reduction, ratio + all four phase-tap-changer flavors, 3-winding star resolution, HVDC, SVC, ExternalNetworkInjection, EquivalentInjection/EquivalentBranch, conform/non-conform loads, linear + nonlinear shunts, AsynchronousMachine, PowerElectronicsConnection. 14 fixture test files; benchmarked against pypowsybl on 8 conformance configurations (scripts/bench/README.md §6)
Multi-island / disconnected componentsnot surveyednot surveyed⚠️ connected_component_mode=MAIN solves the largest component, drops the rest (verified directly — it is why pypowsybl's bus counts run below gridoxide's on every CGMES fixture)not surveyednot surveyed✅ every connected component solved in one call with a per-island IslandReport/IslandStatus (Converged/MaxIterationsReached/Singular/NoReferenceBus/AmbiguousReferenceBus); sourceless islands get a zero-voltage placeholder rather than an error
Contingency / N-1 batch analysis✅ ContingencyAnalysis, reuses factorization, ~20x speedup claimed❌✅ + Woodbury fast-DC path✅ linear and nonlinear (full AC) contingency analysis, a HELM-based variant, SRAP support, and a time-series variant✅ contingency module, with a run_contingency_ls2g variant that offloads the actual solves to lightsim2grid for speed✅ DC: linear::sensitivity::DcSensitivity::outage_flows gives post-outage flows from one triangular solve with no refactorization, and multi_outage_flows generalizes it to N-k via Woodbury (0.40 ms per pair on case9241pegase against a 13.4 ms re-solve), including is_breaking_set for outage sets that disconnect the network. ✅ AC: batch::BatchSolver::solve_contingencies, 2.0x (case118) to 2.7x (case9241pegase) against independent solves single-threaded — network::build_ybus_with_outages takes a branch out while keeping its structural entries, so the symbolic factorization carries across a whole N-1 sweep. Contingencies that genuinely sever the network fall back to a full rebuild, which is what lets an islanded contingency report NoReferenceBus honestly. Note the plain injection-batch path (BatchSolver::solve) still refuses Scenario::branch_outages with BatchError::OutagesUnsupported; contingencies go through the dedicated method above. See gap 2 below
Time-series / batch injections✅ TimeSerie, ~13x speedup claimed✅ batch datasets, parallel via threading param—✅ time-series variants of power flow, OPF, linear analysis, and contingency analysis✅ timeseries module (run_time_series, pluggable DataSource/OutputWriter)⚠️ batch::BatchSolver (crates/gridoxide-core/src/batch.rs): many scenarios over one shared topology, parallel across cores via rayon, each worker amortizing one symbolic factorization over its share — 3.5x on 8 physical cores at 256 scenarios (scripts/bench/README.md §4b), results identical to a sequential loop and returned in scenario order. Injection overrides only (BusOverride deliberately cannot change bus_type, since that changes n_unknowns and invalidates the shared pattern), and no time-series driver layered on top — no DataSource/OutputWriter equivalent, no result writer. The deeper gap is chronology: scenarios are independent, so nothing carries state from one step to the next — storage state of charge, tap positions, controller memory — which is what separates a batch from a genuine quasi-static time series. simbench ships the profiles such a driver would be validated against
Input validation❌✅ validate_input_data/validate_batch_data—❌ no generic equivalent found (only format-specific CIM/FMU import validation)✅ diagnostic() (disconnected elements, implausible values, wrong reference system, ...)❌
Short-circuit calculation❌✅ (IEC 60909)❌✅ (3-phase, LG, LL, LLG fault types — Simulations/ShortCircuitStudies/)✅ (IEC 60909-style, shortcircuit module)✅ IEC 60909, phase-domain (crates/gridoxide-core/src/shortcircuit/): all four fault types (3-phase, LG, LL, LLG), c_max/c_min voltage scaling, bolted and impedance faults, multiple simultaneous faults, de-energized-island handling. Results in both bases — phase quantities and symmetrical components (the Fortescue view powsybl's API models, which PGM does not offer). Cross-validated against all 15 of power-grid-model's own short-circuit fixtures (tests/pgm_short_circuit_test.rs); CLI (gridoxide short-circuit) and Python (gridoxide.short_circuit). No study types/machine reactances, no derived \(i_p\)/\(I_b\)/\(I_{th}\) — see The Short-Circuit Problem
State estimation❌✅ (WLS, sym + asym, iterative-linear + Newton-Raphson, voltage/power/current sensors, batched with topology caching and thread-parallelism; no bad-data detection)❌✅ (WLS + observability analysis + pseudo-measurement augmentation)✅ (WLS, estimation module)✅ both domains (WLS + observability + bad-data detection + zero-injection constraints, both PGM calculation methods, batched with thread-parallelism and a shared factorization, voltage/power/current sensors in both angle frames). Symmetric, one bus per node. And asymmetric, in the phase domain: three buses per node laid out 3·node + phase, with each asym sensor reading its own phase against a line-to-neutral voltage base rather than reducing to a balanced total — which is what makes an unbalanced network estimable rather than approximable, and on a distribution feeder the unbalance is the question. Nothing in the estimator changed for it: Target, StateLayout, the Jacobian, the constraints, both methods and the batch solver are the same code, because a three-phase branch terminal is a six-coefficient CurrentFunctional where a scalar one has two. pgm::se::se_case_from_pgm / se_case_from_pgm_3ph are the single entry point for both, and gridoxide estimate [--asymmetric] reaches them. Gated against power-grid-model's published asymmetric answer for transmission-case (all 33 phase-buses to 1e-6), and — since the analyses were only ever claimed to carry over — observability, bad-data detection and the batch solver are each now gated in the phase domain too, the last bit-for-bit against a sequential loop. One limit found in the doing and gated rather than papered over: Gauss-Newton takes an undamped step, so a measurement far enough from consistent diverges — on this fixture at 20σ in the phase domain and 50σ symmetrically — and reports MaxIterations rather than a confident answer. What remains is a modelling difference about sources, described in the note below, and nothing else: every component the symmetric path reads, the phase domain now models
Sensitivity analysis / OPF❌ / ❌❌ / ❌✅ / ❌✅ (PTDF/LODF, Simulations/LinearFactors/) / ✅ (linear and nonlinear AC OPF, Simulations/OPF/)✅ (PTDF, pypower/makePTDF.py) / ✅ native PDIPM AC+DC OPF (runopp/rundcopp) plus an optional external Julia PandaModels.jl bridge (runpm.py) for more advanced formulations✅ DC: PTDF/LODF plus N-k outage factors (linear::sensitivity::DcSensitivity, exact because DC is linear) — see DC power flow. ✅ AC: ac_sensitivity::AcSensitivity differentiates a converged operating point against active/reactive injection, transformer ratio and phase-shifter angle, for branch P/Q and bus voltage — both forward (one solve per variable) and adjoint (one solve per monitored quantity), against a single Jacobian factorization. Validated by central-difference re-solve (tests/ac_sensitivity_test.rs); CLI (gridoxide sensitivity) and Python (AcSensitivityModel). No AC contingency sensitivities and no differentiation through the outer loops — see AC Sensitivity Analysis. / ⚠️ DC-OPF (opf::dc): least-cost dispatch as a convex QP over generator active power and load shedding, subject to generator boxes, branch ratings and the DC balance, reporting dispatch, locational marginal prices and binding limits with shadow prices. Solved through a solver-independent LinearProgram/Solution boundary with two backends: gridoxide's own primal-dual interior-point QP solver (opf feature — pure Rust, no system libraries, so it is the default and is covered by CI) and HiGHS via gridoxide's own bindgen FFI (opf-highs, the reference the two are cross-checked against on the fixtures and on 300 randomized convex QPs). Validated by analytic cases, KKT optimality certificates and pglib-opf's published DC objectives (all five cases inside 0.03%). CLI (gridoxide opf) and Python (dc_opf). ✅ AC-OPF (opf::ac): the full nonconvex problem — generator P and Q, voltage magnitudes and angles, apparent-power branch limits — via gridoxide's own nonlinear interior-point method on the injection Hessians of injection_hessian. All five pglib cases match the published AC objectives to 0.001%. Locally optimal, as every AC-OPF is; cross-checked against IPOPT (opt-in opf-ipopt, own bindgen bindings against a system install) to 5e-9 relative on every fixture. CLI and Python. No taps/phase shifters as variables, no unit commitment, no security constraints — see Optimal Power Flow.
Pluggable "outer loop" architecture❌⚠️ ad hoc (tap optimizer only)✅ extensively (14+ outer loops)⚠️ ad hoc (boolean control flags in PowerFlowOptions, not a modular/registry-based architecture like powsybl's)✅ genuine Controller/BasicCtrl base classes (control/basic_controller.py) registered on net.controller and driven by run_control — third-party code can subclass Controller directly, closer in spirit to powsybl's extensibility than to VeraGrid's/PGM's fixed flag sets, though not the same formal outer-loop-convergence architecture⚠️ internal, not extensible: outerloop::OuterLoop plus a driver over an ordered list, with four loops (distributed slack, reactive limits, phase control, transformer voltage control). The schedule is powsybl's — nested, innermost first, each loop run to its own stability before the next is consulted, re-walking on any change and terminating on reaching the last loop that moved something. Deliberately not ServiceLoader-style discovery: the list is built by the crate. What it bought is composition, which was the actual defect — before it, newton_raphson_enforcing_q_limits and newton_raphson_distributing_slack were separate entry points and a caller could have at most one. See Outer Loops
RMS / transient stability (phasor-domain dynamics)❌❌❌✅ Simulations/Rms/❌✅ src/dynamics/ (dynamics feature): the differential-algebraic system solved simultaneously — one Newton per step over device states and network voltages together, in rectangular current balance so the network block is the constant real form of the Y-bus, on the same five sparse backends the power flow uses. Trapezoidal with backward-Euler damping after each discontinuity. Machines classical / transient / salient-pole / subtransient; SEXS and a proportional regulator; TGOV1 and a proportional governor; a washout-plus-lead-lag stabilizer; ZIP loads with a low-voltage cutoff. Bus faults, branch and unit switching, load steps — all value-only, so one symbolic factorization serves a whole run — plus protection relays on bus voltage, machine speed or angle excursion, whose crossing times are located inside the step rather than rounded to it. Reads gridoxide JSON, PSS/E .dyr, and a whole Dynawo case — IIDM network plus .dyd/.par — in one call; CLI (gridoxide dynamics) and Python (dynamics). Gated against the equal-area criterion's closed-form critical clearing time (12 µs), a finite-difference Jacobian oracle over every model, and Dynawo's own published answer for Kundur's Example 13.2 (δ₀ to 6e-5 rad, rotor angle through the fault to 9e-4 rad). Non-windup limits on the exciter's field voltage and the governor's valve, carried through from both readers. Not yet: saturation. The classical ω ≈ 1 stator approximation is the default and costs a measured 0.6% of terminal power per 0.9% of speed deviation; Dynawo's fuller form (speed voltages carried, swing equation in torque) is one flag away and removes 96% of the disagreement with it. See The RMS Simulation Problem
EMT (electromagnetic transients)❌❌❌✅ Simulations/EMT/❌❌ — sub-cycle three-phase time stepping, switching devices, travelling-wave lines. The furthest of the three
Small-signal / modal (eigenvalue) analysis❌❌❌✅ Simulations/SmallSignalStabilityRms/ + SmallSignalStabilityEmt/❌⚠️ the RMS half is complete; the EMT half is absent, and that is the only reason this is not ✅ (src/dynamics/smallsignal.rs): the DAE linearized about an equilibrium, the algebraic block eliminated (A = A_x − A_v·C_v⁻¹·C_x), eigenvalues, damping ratios, modal frequencies, participation factors naming the states each mode belongs to, and mode shapes saying how the rotors move relative to one another — which is what separates an inter-area mode from a local one. The four blocks are not re-derived — they are the ones DaePattern::fill already assembles for every Newton iteration, so the linearization cannot drift from the simulation it describes. Two methods, and the result says which answered. Dense (analyze) computes every mode and is the default below two thousand states. Past that, or whenever a shift is named, sparse shift-invert Arnoldi (analyze_near, src/dynamics/arnoldi.rs) computes the k modes nearest a point in the complex plane without ever forming A: the operator is the Schur complement of the bordered pencil, so (A − σI)⁻¹b is one sparse complex solve on the same DaePattern the simulation uses, and a shift adds no fill-in because the state diagonal is structurally present already (src/dynamics/shift.rs). Left eigenvectors — hence participation factors — come from a second Arnoldi pass on the adjoint operator against the same factorization; an ambiguous left/right match is reported without participation rather than with a plausible wrong one. 20 480 states (a 4 096-bus ring, 2 048 sixth-order machines with exciters and governors) in under 15 s, every residual under 3e-14 — where the dense method cannot start. Residuals are measured by applying the operator, not by the textbook estimate, which was measured reporting 8e-52 where the truth was 1.3e-14. ✅ Eigenvalue sensitivities too: dλ/dp, dζ/dp and df/dp from a quadratic form in the pencil's own left and right eigenvectors, with ∂J/∂p a central difference of the assembly rather than a second derivation — restricted, on purpose, to parameters the equilibrium does not depend on (h, d), since for a reactance the true derivative carries a ∂J/∂x·dx/dp term this does not have. Gated against the closed-form swing eigenvalue ±j√(Ω_b·K_s/2H) and its own derivative dλ/dH = −λ/2H; against a difference of two whole analyses; against the time-domain run's observed period (2e-3) and peak-to-peak decay (2%), which the sparse path also meets by exciting one mode along its eigenvector; and against ARPACK on the identical operator (opt-in smallsignal-arpack, own hand-written bindings to arpack-ng's znaupd_c/zneupd_c), which isolates the eigensolver from everything shared. CLI (gridoxide dynamics --modes --modes-freq --modes-near --modes-sensitivity) and Python (small_signal). Not here: the EMT half, and block Arnoldi for genuinely degenerate spectra — a ring of identical machines has near-exact repeated modes and a Krylov space finds one vector per invariant direction, which is gated as a limitation rather than left as a footnote — see Small-Signal Analysis
Remedial action optimization (CRAC / CNEC, preventive + curative)❌❌❌ — this is powsybl-open-rao's job, a separate project, and it is the reference gridoxide is gated againstnot surveyed (its contingency row mentions SRAP support, which is adjacent)not surveyed✅ src/rao/: CRAC model and readers (every one of the 428 CRACs in the reference checkout imports, across 24 format versions — a recorded sweep, not a standing gate; what CI walks is the subset tests/data/openrao-fixtures.txt names), evaluation kernel with Woodbury outage screening, LP over range actions, search tree over network actions, CASTOR perimeter decomposition, automaton simulation, MNEC soft constraints, conditional usage rules, RA usage limits, curative stop criterion, AC re-validation. second preventive with its execution conditions. Gated against the reference's own Cucumber suite — 197 scenarios, 1830 of 1862 checkable assertions (180/186 DC, 276/282 + 963/977 AC, 123/123 second preventive, 288/294 costly optimization). Costly optimization (MIN_COST) in full: network actions, a range action's movement priced at its variationCosts, and its activation as a binary in a MIP. All 32 that differ are recorded disagreements measured on the reference's own objective, in none of which gridoxide is worse — including two where it secures the network more cheaply than the reference asks. No open defect remains. Not yet: loop flows, relative margins, HVDC and counter-trade range actions, an angle/voltage monitoring pass, an integer tap (measured as unjustified — all 24 scenarios asking for APPROXIMATED_INTEGERS already match). See The Remedial Action Problem
Voltage stability / continuation power flow (P-V and Q-V curves, loadability limit)❌ no match for continuation/CPF/loadability in its tree (its cpf hits are dcpf)❌ same❌ samenot surveyednot surveyed✅ both curves and the loadability limit. crates/gridoxide-core/src/qv.rs: Q-V curves and the per-bus reactive margin (gridoxide qv, PowerFlowModel.qv_curve) — a fictitious condenser at the bus, its setpoint swept, the minimum interpolated through the three bracketing samples; gated against a closed-form two-bus Q-V curve to 1e-5 in Q. Worth knowing, and the opposite of the natural assumption: Q-V and P-V do not agree on the weakest bus (on case14, one bus in common out of five, and evaluating both at the same loading makes it worse) — they measure a system-wide collapse mode against one bus's local headroom, which is why both exist. See Q-V Curves. And crates/gridoxide-core/src/continuation/: P-V curves and the loadability limit — tangent predictor, extended-Newton corrector over the bordered (n+1) system, three parametrizations (local by default — pseudo-arclength's dense bordering row measures 92x a sparse one at n = 2449), adaptive step, lower-branch tracing. case9241pegase traces to its nose in 10 s. Saddle-node and limit-induced bifurcations are distinguished; reactive-limit crossings are located exactly (bisection on arclength, matching a brute-force oracle to 1e-6 in λ against 1e-2–1e-1 for step-granularity switching); the weakest-bus ranking is the tangent at the nose. Gated against a closed-form two-bus nose valid including resistance, since no vendored reference implements continuation at all. Not yet: tap control during a trace (refused by name, ContinuationError::TapControlUnsupported), bidirectional PV↔PQ switching, ZIP-load derivatives (refused rather than approximated). See Continuation Power Flow
Harmonic analysis / frequency scan❌ no match for harmonic/frequency-scan in its tree❌ same❌ samenot surveyednot surveyed❌ — needs a per-frequency Y-bus, harmonic source models and frequency-dependent branch data
Reliability / adequacy (Monte Carlo, LOLE/EENS)❌ no match for reliability/monte-carlo/LOLE/EENS in its tree❌ same❌ samenot surveyednot surveyed❌ — the contingency kernel exists; the outage-rate data, sampling driver and indices do not
Protection coordination (relay curves, selectivity)❌ explicitly not simulated — "ignore the protection, that are NOT simulated" in its own examples❌ no match in its tree❌ samenot surveyednot surveyed❌ — would sit on the short-circuit engine that does exist
Input formats beyond CGMES⚠️ ingests from pandapower and pypowsybl/IIDM rather than reading files itself⚠️ own PGM format only✅ IIDM native✅ Matpower, PSS/E RAW✅ Matpower / PYPOWER⚠️ PGM-JSON, own native JSON, UCTE-DEF (src/ucte.rs), IIDM (src/iidm/mod.rs), CGMES — the last giving each galvanically-connected group of buses one voltage base, since a tie line joining a Belgian 380 kV bus to a Dutch 400 kV one is one conductor and per-unit across it does not otherwise close (cgmes::harmonize_voltage_bases; 5 of 13 lines on MicroGrid-Type1 and FullGrid, none elsewhere). MATPOWER .m (src/matpower.rs), read directly with runpf's bus-type and setpoint rules rather than through the PGM conversion, which puts the slack behind a source impedance and rounds phase shifts to 30°. No PSS/E RAW
Sparse solverKLU/Eigen/NICSLU/CKTSO, pluggable at runtimehand-rolled 2×2-block LU, pivot perturbation off by defaultKLU via JNI (primary path)SciPy's SuperLU (scipy.sparse.linalg._dsolve._superlu), wrapped in a numba-JIT'd custom CSC type (Utils/Sparse/csc2.py) — not pluggableSciPy's spsolve (pypower/newtonpf.py), with an optional use_umfpack flag — UMFPACK is a SuiteSparse sibling of KLU, when scikit-umfpack is installedfaer (Scalar) / hand-rolled 2×2-block LU (Block, matches PGM's own block granularity) / KLU (Klu) / from-scratch Rust KLU port (KluNative) / Intel oneMKL PARDISO (Pardiso)

Per-tool notes

lightsim2grid (C++/Python, KLU-backed)

  • Solvers: NR (single-slack and distributed-slack variants), Gauss-Seidel (+ "synch"), DC, fast-decoupled (XB/BX). Linear-solver backend is pluggable (Eigen SparseLU, KLU, NICSLU, CKTSO) via SolverType.
  • Elements: lines, 2-winding transformers (fixed tap ratio + phase-shift angle, changeable only between solves), shunts, loads, static generators, storage, DC lines/HVDC. No 3-winding transformers, no SVC, no switches (explicit TODO in SubstationContainer).
  • Its own docs/disclaimer.rst is refreshingly explicit about what it doesn't do: no Q-limit enforcement, fixed taps mid-solve, steady-state only, symmetric only.
  • ContingencyAnalysis and TimeSerie batch classes reuse Ybus factorization across many solves rather than rebuilding from scratch — same idea as gridoxide's PersistentSolver, just applied to a batch-of-scenarios use case rather than only repeated single-topology solves.
  • Ingests grids from pandapower and pypowsybl/IIDM directly (gridmodel/from_pandapower, gridmodel/from_pypowsybl).

power-grid-model (C++/Python)

  • Calculation types: power flow (sym + asym), state estimation (WLS, with observability checks), short-circuit (IEC 60909, phase-domain). No sensitivity/OPF.
  • PF solver algorithms: Newton-Raphson (default), iterative-current, linear/linear-current (auto-selected when all loads are constant-impedance).
  • No PV bus type in plain power flow ("not supported yet" per its own docs) — PV-like behavior instead comes from the newer voltage_regulator component, which fixes |U| and solves for Q; q_min/q_max exist on it but the automatic PV→PQ switching is explicitly flagged as not fully implemented.
  • Same hand-rolled block-sparse LU architecture gridoxide's Block backend mirrors: per-bus 2×2 real blocks for NR power flow, full pivoting within a block only (no cross-block pivoting), pivot perturbation off by default for ordinary power flow (confirmed at the newton_raphson_pf_solver.hpp call site — this is what caused the SparseMatrixErrors investigated earlier this session).
  • Batch calculations reuse the prebuilt topology graph and matrix prefactorization across scenarios when only load/gen/source setpoints change (not when topology/tap/shunt status changes) — the same invariant PersistentSolver::reset() documents for gridoxide.
  • TapChangingStrategy outer loop (disabled by default): any_valid_tap, min_voltage_tap, max_voltage_tap, fast_any_tap.
  • validate_input_data/validate_batch_data exist but are explicitly not run automatically for performance reasons — recommended for debugging, not the hot path.

powsybl-open-loadflow (Java, RTE)

  • Calculation types: AC power flow, DC power flow, sensitivity analysis (AC+DC, incl. post-contingency), security/contingency analysis (N-1/N-k, AC+DC). No short-circuit, no state estimation.
  • Solvers: Newton-Raphson (primary), Newton-Krylov, fast-decoupled — all pluggable via AcSolverFactory (service-loader based, genuinely extensible). Five voltage-initialization strategies (flat, warm/previous, uniform, DC-angle-based, magnitude-based).
  • Most feature-rich of the three on voltage/reactive control: automatic PV→PQ switching with reactive capability curves, remote voltage control (one generator regulating a different bus), shared voltage control among multiple controllers, a priority scheme (generators > transformers > shunts), and even secondary voltage control (research-based).
  • Distributed slack: on generators, loads, or "conform" loads; manual or automatic slack-bus selection with multiple strategies (first, largest-generator, most-meshed, named); also area-interchange-based distribution.
  • Genuinely modular outer-loop architecture — OuterLoop/OuterLoopContext/OuterLoopResult abstractions, extensible via ServiceLoader, with 14+ concrete outer loops (distributed slack, area-interchange, reactive limits, transformer voltage/reactive-power control, phase control, shunt voltage control, secondary voltage control, HVDC AC-emulation limits). This is the architecture responsible for nearly every "extra" feature above the bare NR solve.
  • Contingency analysis performance claim is best substantiated for DC specifically (Woodbury-formula fast path, WoodburyEngine/WoodburyDcSecurityAnalysis) — AC contingency/sensitivity analysis is documented as reusing full-resolve-style computation, and its own README's "Contributing" section flags AC performance as an open area, so the tool's reputation for contingency-analysis speed is strongest for DC, not universal.
  • Supports asymmetric/unbalanced modeling (LfAsym* classes) and full node-breaker topology with connectivity traversal (NodeBreakerTraverser).
  • Uses powsybl-math's LUDecomposition/MatrixFactory abstraction; native KLU via JNI is the primary path (same library gridoxide's own Klu backend vendors directly).

VeraGrid (Python, SanPen/VeraGrid, the GridCal successor)

  • By far the broadest simulation-category scope of the five — installed as the headless VeraGridEngine package (not the Qt-GUI-bundled VeraGrid package), its Simulations/ directory alone has 25+ top-level categories: beyond power flow, also OPF (linear + nonlinear AC), state estimation, short-circuit, contingency analysis, sensitivity (PTDF/LODF), continuation power flow (PV curves), stochastic/Monte Carlo analysis, reliability analysis, investment/expansion-planning evaluation, net/available transfer capacity (NTC/ATC), topology reduction, and — uniquely among all five — electro- magnetic-transient (EMT) and RMS time-domain dynamic simulation with small-signal stability analysis. pandapower rivals it in raw feature count (see below) but has no equivalent of EMT/RMS dynamic simulation at all; lightsim2grid/PGM/powsybl are themselves narrower, purpose-built power-flow-focused engines by comparison.
  • Solvers: among the most pluggable of the five via SolverType — NR, Gauss-Seidel, Fast-decoupled, Levenberg-Marquardt, Iwamoto-NR, Powell's Dog Leg, HELM (Holomorphic Embedding), Decoupled-LU, plus linear/ linear-AC modes and dedicated linear/nonlinear OPF solver types, all in one PowerFlowOptions.solver_type enum — though pandapower's own algorithm parameter is comparably broad and additionally offers a backward/forward-sweep solver neither VeraGrid nor any of the other four tools here have.
  • Voltage/reactive/tap control is a set of independent boolean flags on PowerFlowOptions (control_q, distributed_slack, control_remote_voltage, control_taps_modules, control_taps_phase, orthogonalize_controls) applied inside the NR iteration itself, not a modular outer-loop registry the way powsybl's OuterLoop abstraction is — closer in spirit to power-grid-model's ad hoc tap optimizer than to powsybl's extensible architecture.
  • Devices include 3-winding and generic N-winding transformers, switches (with CIM/IIDM node-breaker import), HVDC lines, VSC, and UPFC, plus a ControllableShunt device (stepped Bmin/Bmax regulating a bus's voltage to a setpoint) filling the SVC role neither lightsim2grid, PGM, nor powsybl have — broad FACTS coverage, though pandapower's own dedicated create_svc/create_tcsc/create_ssc set turns out broader still (see below).
  • MATPOWER import (parse_matpower_file) reads each bus's type column and each generator's Vg setpoint directly, so genuine PV-bus modeling comes for free with no PGM-voltage_regulator-style conversion step — see scripts/bench/bench_veragrid.py.
  • Its own numerical kernels are numba-JIT-compiled (first call per process pays a multi-second JIT-compilation cost unrelated to the power-flow algorithm itself — scripts/bench/bench_veragrid.py's warm-up call absorbs this) and its sparse LU solve is SciPy's SuperLU (scipy.sparse.linalg._dsolve._superlu.gstrf, wrapped in a numba-jitted custom CSC type, Utils/Sparse/csc2.py) — not pluggable across multiple sparse backends the way lightsim2grid or powsybl are.
  • On the 12-case real-MATPOWER benchmark (scripts/bench/README.md), converges on 9 of 12 (the same three hard RTE cases every tool but gridoxide/pandapower also fails on) and lands roughly on par with pypowsybl — markedly slower than the C/Rust-backed solvers here, consistent with being a general-purpose Python framework rather than one optimized around raw repeated-solve throughput.

pandapower (Python, e2nIEE/pandapower)

  • Also very broad in scope, though — unlike VeraGrid's from-scratch simulation engines — pandapower's own numerical power-flow/OPF path is largely a thin, numba-accelerated wrapper around PYPOWER (pandapower/pypower/, itself a Python port of MATPOWER), with pandapower supplying the richer network model (switches, controllers, 3-winding transformers, FACTS devices) and everything else (contingency, timeseries, estimation, shortcircuit, diagnostic) as sibling top-level packages built on top of that core.
  • Solvers (runpp(algorithm=...)): "nr" (default, PYPOWER's Newton-Raphson, numba-accelerated), Iwamoto-NR ("maybe slower... but more robust" per its own docstring), backward/forward sweep ("bfsw", specially suited to radial/weakly-meshed networks — a solver category none of the other five tools here offer), Gauss-Seidel, and two explicitly separate fast-decoupled variants ("fdbx"/"fdxb"), plus HELM.
  • Switches (create_switch/create_switches, bus-bus/bus-line/bus-trafo) are core to how pandapower represents topology at all, not a bolted-on extra the way they are for some other tools here — closest in spirit to powsybl's node-breaker model among the tools surveyed.
  • FACTS-device coverage (create_svc/create_tcsc/create_ssc/create_vsc*) is the broadest of the six tools surveyed, including a thyristor-controlled series capacitor (TCSC) none of the others model.
  • The generic Controller/BasicCtrl framework (control/basic_controller.py, driven by run_control=True) is genuinely extensible — any third-party code can subclass Controller and register it on net.controller — closer to powsybl's outer-loop extensibility in spirit than to PGM's/VeraGrid's fixed option flags, even though the underlying convergence-loop architecture isn't identical.
  • contingency module includes a run_contingency_ls2g variant that offloads the actual repeated solves to lightsim2grid for speed — a real cross-tool dependency between two of the tools surveyed here, not just a coincidental feature overlap.
  • OPF is two-tiered: a native, no-external-dependency PDIPM-based AC/DC OPF (runopp/rundcopp, inherited from PYPOWER) for standard formulations, plus an optional bridge to Julia's PandaModels.jl (runpm.py) for more advanced formulations (storage, multi-stage, etc.) when that external toolchain is installed.
  • diagnostic() (diagnostic/diagnostic_helpers.py) is a real, generic input-validation/consistency-check function (disconnected elements, implausible parameter values, wrong reference system, ...) — closer to PGM's validate_input_data than to VeraGrid's format-specific-only import validation.
  • Also has a dedicated protection package (protection-device/relay-coordination modeling) that none of the other five tools here have any equivalent of — outside the scope of this table's rows, but worth noting as another area where pandapower's breadth exceeds a pure power-flow-engine comparison.
  • This is the same pandapower already used elsewhere in this benchmark suite (bench_pandapower.py, bench_lightsim2grid.py's and lightsim2grid's own init_from_pandapower) — see Backends and Factorization Reuse and scripts/bench/README.md for its own timing numbers, where it's the only tool besides gridoxide to converge on all 12 real MATPOWER cases.

Where gridoxide already exceeds or matches

  • Asymmetric power flow: already solving and tested (matches PGM/powsybl/VeraGrid/pandapower; lightsim2grid doesn't have this at all).
  • 3-winding transformers: already have a passing fixture (matches PGM/powsybl/VeraGrid/pandapower; lightsim2grid doesn't have this at all).
  • Factorization reuse across repeated solves (PersistentSolver): conceptually identical to what lightsim2grid's ContingencyAnalysis/TimeSerie and PGM's batch-calculation path rely on.
  • Batched solving over one topology (batch::BatchSolver): the API layered on top of that reuse, and the shape time-series/QSTS and Monte Carlo runs actually need — thousands of independent scenarios over an unchanging topology, spread across cores on rayon's own pool, one cached symbolic factorization per worker. Matches PGM's batch-calculation path and lightsim2grid's TimeSerie on the injection-scenario case; still short of both on contingency, which needs per-scenario topology (see gap 2 below). (bde::solve_batch_block_diagonal stacks a batch into one block-diagonal factorization instead, validated bit-exact against independent per-scenario solves in scripts/bench/README.md §4d — but it is ~2.7x slower on a CPU and exists to validate a future GPU path's architecture, so it is not a batching capability this table should credit.)
  • Block-sparse LU backend granularity: matches PGM's own per-bus 2×2 block design, and gridoxide's faer-backed solve handles pivots PGM's own hand-rolled solver refuses (no pivot perturbation) on the same real transmission-scale data — still true after the converter fixes below, which changed PGM's input but not its failure pattern (same 6 SparseMatrixError / 4 IterationDiverge cases as before).
  • Sparse-solver breadth: five backends (Scalar/Block/Klu/KluNative/Pardiso — the count previously read "four" while listing five) already exceeds VeraGrid's and pandapower's single fixed-solver paths, though it's still short of lightsim2grid's runtime-pluggable KLU/Eigen/NICSLU/CKTSO selection.
  • CGMES import depth: one of four tools here with any CGMES/CIM import at all, and the only one of those four that is otherwise a focused AC power-flow library rather than a general-purpose framework. On the 8 conformance configurations benchmarked in scripts/bench/README.md §6 it is faster than pypowsybl on every fixture where both actually solve, and solves MicroGrid-Type2-HVDC-MAS, which pypowsybl declines to attempt (iteration_count=0, "Network has no generator with voltage control enabled").
  • Multi-island solving: solves every connected component with per-island status rather than only the main one.
  • Remedial action optimization: the one row where none of the five comparison tools competes — the reference is powsybl-open-rao, a separate project. And it is the most externally-validated thing here: the reference publishes its expectations as a Cucumber suite, so 156 of its own scenarios are read unmodified from the benchmark-grids submodule and scored assertion by assertion (tests/rao_cucumber_test.rs), rather than gridoxide checking its own arithmetic against itself.
  • Solution verification tooling: scripts/bench/check_matpower_residual.py checks a solved case against the MATPOWER file's own power-flow equations, and check_cgmes_sv_consistency.py checks a CGMES fixture's published SvVoltage against its own EQ/SSH data. Neither needs a second tool as a reference. This is a benchmark-harness capability, not an input-validation feature — it does not close the "Input validation" row above, which is about validating input before a solve (PGM's validate_input_data, pandapower's diagnostic()).

Identified gaps, ranked by how often reference tools flag them as important

  1. Q-limit enforcement / PV→PQ switching — every one of the five either has it, half-has it, or explicitly disclaims not having it as a known limitation. gridoxide's Bus already carried q_min/q_max, unused. Done — outerloop::ReactiveLimits implements the standard MATPOWER-style one-directional PV→PQ switching outer loop, tested in tests/q_limits_test.rs across all three Jacobian backends; PgmVoltageRegulator now parses PGM's own q_min/q_max fields. Opt-in: plain newton_raphson/PersistentSolver::solve are unchanged, so no existing test/benchmark behavior shifted. It was solver::newton_raphson_enforcing_q_limits, a standalone entry point, until the outer-loop layer made it composable with the two below.

  2. Contingency/N-1 batch analysis — the one gap with most of its machinery already standing: PersistentSolver's factorization reuse and batch::BatchSolver's across-scenario parallelism are exactly what lightsim2grid, powsybl, VeraGrid (the most comprehensive, with linear, nonlinear, and HELM-based variants), and pandapower (which even offloads some of its own contingency solves to lightsim2grid for speed) build this on. Done for DC, which is the mode these tools actually screen contingencies in: linear::sensitivity::DcSensitivity::outage_flows gives post-outage flows from one triangular solve with no refactorization (and reports radial branches, which have no redistribution factors), while linear::batch::DcBatchSolver runs injection scenarios against a single factorization for the whole batch — 9x to 55x per scenario against independent solves, see scripts/bench/README.md §8. N-2 and N-k are covered too, via the generalized (Woodbury) formulation in multi_outage_flows — 0.40 ms per pair on case9241pegase against a 13.4 ms re-solve — including is_breaking_set for outage sets that disconnect the network, which single-branch screening misses by construction. AC contingency is done as well, via batch::BatchSolver::solve_contingencies. The old objection recorded here — "a branch outage gives each scenario its own Y-bus and therefore its own sparsity pattern" — turned out to be half right: the Y-bus does differ per scenario, but the pattern need not, because network::build_ybus_with_outages takes a branch out while keeping its structural entries. The symbolic factorization therefore carries across a whole N-1 sweep; only the Jacobian's cached admittances are re-derived, at O(nonzeros). Measured at 2.0x (case118) to 2.7x (case9241pegase) against independent solves single-threaded, before any parallelism. Contingencies that genuinely sever the network cannot use that trick — connected_components cannot distinguish a zeroed structural entry from a live one — so they are detected up front and fall back to a full rebuild, which is what lets an islanded contingency report NoReferenceBus honestly instead of a singular solve.

  3. Distributed slack — 4 of 5 tools have it (lightsim2grid, powsybl, VeraGrid, pandapower); real transmission grids split slack across several generators, and a single slack absorbing an unscheduled few hundred megawatts distorts every branch flow around it. Done — outerloop::DistributedSlack, an outer loop in the same shape as the Q-limit one: solve, compare what the slack actually produced against what its own p_spec schedules it for, move the difference onto the participating generators by normalized weight, solve again.

    Two things are worth recording. The slack's schedule is its own p_spec, a field ordinary power flow ignores for a slack bus because the slack's output is an answer rather than an input — so a document that never needed it may leave it at zero, in which case the entire output is redistributed. And the outer loop is cheap in a way the Q-limit loop is not: only p_spec changes between passes, so bus types, n_unknowns and the Jacobian's sparsity pattern all hold and PersistentSolver keeps its symbolic factorization throughout.

    Convergence is linear, not one-and-done. The first-order term cancels exactly in a single pass — distributing \(\Delta\) adds \((1-\alpha_s)\Delta\) to the other participants while the slack's own schedule rises by \(\alpha_s\Delta\) — but the leftover is the change in transmission losses from the redistributed flows, which shrinks by a few per cent per pass rather than vanishing. Measured at tolerance = 1e-8: case14_ieee, case30_ieee and case118_ieee each take seven passes, moving 2.3, 2.4 and 16.5 per-unit off their slacks. Tested in tests/distributed_slack_test.rs, with every check re-deriving injections from network::power_injections rather than reading the loop's own bookkeeping.

    newton_raphson_distributing_slack is now outerloop::DistributedSlack, one loop among the ordered list of gap 11's entry, which is what lets it run together with Q-limit enforcement — the two used to be alternatives. Both are reachable from gridoxide solve --distribute-slack --enforce-q-limits and from PowerFlowModel.solve(distribute_slack=True, enforce_q_limits=True); the "library-only" note this paragraph used to carry is out of date.

    Area interchange is done too, as outerloop::AreaInterchange. It is the same mechanism generalized from one balance target to several, which is why powsybl's AcAreaInterchangeControlOuterLoop constructs a DistributedSlackOuterLoop as its no-area fallback — and here that relationship is an assertion rather than a remark: one area with a zero target reproduces distributed slack's per-bus shifts and solved voltages exactly.

    Building it turned up something the earlier sketch of this paragraph missed. Both ends of a tie line are measured into the branch, so the positions sum to the tie's losses rather than cancelling, and a set of agreed net positions — which sums to zero — is unachievable by exactly that amount. The knobs are one aggregate schedule per area, \(N\); the conditions wanted are \(N\) positions plus the slack on its own schedule, \(N+1\). Over-determined by one, always. So the slack's own area is the dependent one: its target is not enforced, it absorbs the tie losses, and the report names it and states the residual rather than implying it.

    Both importers are wired now. CGMES supplies both halves — ControlArea.netInterchange for the schedule, TieFlow for the boundary — and membership is derived by cutting the tie branches and taking connected components, because CGMES never lists what is inside an area. MicroGrid is the tree's genuine two-area fixture and the loop puts BE on its declared −236.977 MW exactly. UCTE supplies membership from its ##Z country codes and no schedule, which is all any UCTE file states; the twelve-node case is a real four-country interconnection. Both reach gridoxide solve --area-interchange.

    Two things the fixtures corrected. A TieFlow names the terminal at the boundary — the X-node two areas' lines meet at — not inside the area, so the seed is the cut branch's far end; seeding from the named end gave five contested buses and one area owning nothing. And the sign: CGMES states an import, AreaDefinition wants an export, checked against SmallGrid's own published state sitting at 210.271 MW against its declared 210.

    IIDM is wired too, and is the only importer that states membership directly — <iidm:area> lists its voltage levels, where CGMES gives only a boundary and UCTE a country per node. The twelve-node case exists in both UCTE and IIDM and their areas agree to 1e-6 (BE +2000 MW, DE −2500, FR +1000, NL −500), by entirely separate code on each side.

    One thing real data forced. AreaDefinition::uniform participates Slack and PV buses only, inherited from distributed slack — right for frequency response, wrong for a net position, which is met by redispatch, and a generator on a fixed active set-point is exactly what gets redispatched. powsybl's own two_area_case.xiidm has both of AREA2's machines at voltageRegulatorOn="false", so under uniform that area had no participant and its −400 MW schedule was unreachable. AreaDefinition::by_generation weights by p_spec instead and the CLI uses it; AREA2 then reaches −400.000 exactly.

    Still absent: nothing in the tree states a net position for a UCTE network, and PGM JSON has no area concept at all. See Area Interchange Control.

  4. DC power flow as a first-class mode — every reference tool treats this as a basic offering. Done, and in the doing it turned out the old single "DC / linear power flow" row was conflating two unrelated algorithms, which is why the summary table above now carries two. The premise that linear_initial_guess was "most of the way there" was only true for one of them: that function is power-grid-model's CalculationMethod.linear (complex, constant-admittance, keeps resistance and produces |V|), and it is now exposed as linear::impedance::linear_power_flow. What was genuinely missing was real Bθ — the thing lightsim2grid, powsybl-open-loadflow and pandapower's rundcpp mean by DC — and that is new code in linear::btheta, with both of powsybl's approximation variants, phase-shifter support, per-island factorization, and PTDF/LODF sensitivities on top. See docs/src/powerflow/dc.md and linear_impedance.md; tested in tests/dc_powerflow_test.rs and tests/dc_sensitivity_test.rs.

  5. Switches, HVDC and SVC as first-class model elements — no longer absent, but reachable only through CGMES import: switching state, converter setpoints and SVC regulation are all fixed at import time, with no element in gridoxide's own network model to change between solves. That is exactly the shape lightsim2grid's disclaimer calls out for its own fixed taps, and it is what stands between the current support and the contingency/time-series work in item 2.

  6. Short-circuit calculation — Done. This was previously listed in item 6 below as outside gridoxide's scope; it is not any more. crates/gridoxide-core/src/shortcircuit/ implements IEC 60909 in the phase domain: all four fault types, both voltage-scaling choices, bolted and impedance faults, several simultaneous faults, and de-energized islands, with results reported in phase quantities and symmetrical components. The premise that made it affordable is that gridoxide was already sequence-in, phase-assembled — Line3Ph carries r0/x0, transformer_seq_params returns (y0, y1, y2), and network::fortescue_to_phase was already building the phase-domain Y-bus from them — so the new code is mostly fault boundary conditions on top of tested assets.

    Building it closed three genuine gaps in the shared three-phase model, each found by a fixture rather than by inspection: transformer_seq_params supported only two winding pairs and panicked on the rest (it now implements power-grid-model's general zero-sequence algorithm, covering YNd, zigzag and the low-susceptance rule); Line3Ph had no conductance field, silently dropping the tan δ dielectric-loss term; and the three-phase path dropped link components entirely, which quietly de-energizes whatever hangs off one.

    Validated against all 15 of power-grid-model's short-circuit fixtures. Eleven match to its own 1e-8; four cannot, because power-grid-model changed its zero-sequence regularization in November 2025 and never regenerated fixtures dating from 2023 — an inconsistency inside the reference set rather than a choice gridoxide can make differently. tests/data/pgm/short_circuit/README.md and KnownDivergence in the test record which, why, and by how much.

  7. Sensitivity analysis — Done for both models, and it was previously listed in the item below as outside gridoxide's scope. The DC factors (PTDF, LODF, and the Woodbury N-k generalization) were already there under item 2; what is new is ac_sensitivity::AcSensitivity, which differentiates a converged AC operating point directly.

    The premise that made it cheap is that the derivative needs no new matrix: \(dx/dp = -J^{-1}\,\partial g/\partial p\) uses the same Jacobian Newton already assembles, and for an injection the right-hand side is a unit vector, so one triangular solve answers for the whole network. The \(\partial f/\partial x\) row for a branch flow is branch_flow::terminal_flow_derivs, written for the state estimator and reused unchanged. The only genuinely new numerics are the tap derivatives, which reduce to a scaling of each admittance entry by itself.

    Both directions are offered against one factorization — forward (one solve per variable, "what does this move?") and adjoint (one solve per monitored quantity, "what would move this?") — with sparse::RealFactorization::solve_transpose reusing the same LU rather than factorizing the transpose. Variables are active and reactive injection, transformer ratio and phase-shifter angle; functions are branch P/Q at either terminal and bus voltage magnitude or angle.

    Validated against a central-difference re-solve of the full nonlinear power flow, which shares no code with the module. One finding worth recording: a tapped branch's own flow carries a direct \(\partial f/\partial p\) term on top of the chain rule, and dropping it flips the sign of that branch's own sensitivity (\(-0.276\) to \(+0.286\) on distribution-case) while leaving every other branch correct — a failure mode that looks entirely plausible in the output.

    Still absent: AC contingency sensitivities (an outage is a finite topology change, not a derivative — BatchSolver::solve_contingencies re-solves instead) and differentiation through the outer loops.

  8. DC-OPF (opf::dc, opf feature) — least-cost dispatch as a convex QP, with locational marginal prices and binding-limit shadow prices, behind a solver-independent boundary. Two backends sit behind it: an in-house interior-point QP solver needing nothing installed, and HiGHS as a cross-checked reference. Validated against analytic cases, KKT certificates, pglib-opf's published DC baseline (all five cases inside 0.03%), and solver-vs-solver agreement.

    AC-OPF (opf::ac) as well: the full nonconvex problem over generator P and Q, bus voltage magnitudes and angles, with apparent-power branch limits, solved by gridoxide's own nonlinear interior-point method (opf::nlp — line search, adaptive regularization, gradual barrier) on the injection Hessians of injection_hessian. All five pglib cases match the published AC objectives to 0.001% at violations of 1e-9 or better; derivatives are checked against finite differences and prices against a numerical d(cost)/d(load). A KKT point of a nonconvex problem is locally optimal, which is what every AC-OPF tool reports. CLI (gridoxide opf --ac) and Python (ac_opf). See Optimal Power Flow.

    Still absent: taps and phase shifters as decision variables, unit commitment, and security-constrained OPF.

  9. Remedial action optimization — Done, and it is not a row any of the five comparison tools fills: the reference here is powsybl-open-rao, a separate project from open-loadflow. src/rao/ carries the CRAC model and readers, the evaluation kernel, an LP over range actions, a search tree over network actions, the CASTOR perimeter decomposition, automaton simulation, MNEC soft constraints, conditional usage rules, RA usage limits and the curative stop criterion, with an AC re-validation stage on top.

    What makes it unusual among the items here is the gate: the reference ships its expectations as a Cucumber suite, so 197 of its own scenarios are read unmodified from the benchmark-grids submodule and scored — 1830 of 1862 checkable assertions, 180/186 DC, 276/282 plus 963/977 AC, 123/123 second preventive and 288/294 costly optimization, with nothing left unsupported out of what was 177 steps. Forty-odd real defects were found by it, every one internally consistent and externally wrong, and several only after a confident diagnosis had to be withdrawn. See The Remedial Action Problem for the mathematics.

    All 32 assertions that do not match are recorded disagreements rather than a backlog: each has been measured on the reference's own objective and in none is gridoxide worse — its BestTapFinder rounding a set-point blind to a virtual cost its own javadoc warns about, and two where gridoxide secures the network more cheaply than the reference asks. The last open defect closed by reading the reference's own source rather than guessing an eighth mechanism.

    Genuinely absent: loop flows, relative margins, HVDC and counter-trade range actions, the angle/voltage monitoring pass, MARMOT multi-timestamp and CNE XML export — and an integer tap, which was measured as unjustified rather than assumed: all 24 scenarios whose configuration asks for APPROXIMATED_INTEGERS already match with continuous optimization and rounding, including six the reference built as controlled pairs against their own continuous twins.

  10. The analysis types with no row until this revision — voltage stability, harmonics, reliability, protection coordination, and the three dynamics rows. None of them is a refinement of something already built; each is a different question asked of the network, and they are worth ranking rather than lumping together:

    • Voltage stability / continuation power flow was the cheapest by a distance, and is now built (crates/gridoxide-core/src/continuation/) — a predictor–corrector loop around the Newton solver that already handled Q-limits, distributed slack and multi-island, answering the question nothing else in the set answers: how much further can this be loaded? Q-V curves are built too (crates/gridoxide-core/src/qv.rs), so the row is complete. The estimate held: the reusable parts were the Jacobian pattern, the reactive-limit outer loop and the sparse backends, and the genuinely new numerics are small. What the estimate missed is that two of the three outer loops must not be run inside the corrector — they move p_spec as a function of the solved state, which corrupts the tangent — and are folded into the loading direction analytically instead. dynawo-algorithms offers voltage margin and load-increase-to-collapse as an external cross-check.
    • Quasi-static time series is next, and half-built: BatchSolver already runs many scenarios over one topology in parallel. What is missing is chronology — carrying storage state of charge, tap positions and controller memory from one step to the next — plus a driver and a result writer. simbench ships the profiles.
    • RMS dynamics was the largest, and is now built (src/dynamics/) — the DAE, its integrator, discrete events, the machine/exciter/governor/stabilizer library, three readers, and the surfaces. The estimate that the corpus and methodology would come close to free held, and better than expected: Dynawo's repository ships its own solver's reference outputs alongside each example, so the external gate needed no Dynawo install at all — a sparse clone of the example files was enough. What the estimate missed is where the real risk lay. It was not the integrator, which is a hundred lines and gated by closed forms; it was the conventions. Published statements of the subtransient machine disagree about the sign of ψ_2q, and saturation is stated incompatibly by PSS/E and Dynawo. Both had to be settled by derivation and by declining to guess, not by copying a source. And the one difference no self-consistency check could ever have found — the ω ≈ 1 stator approximation, worth 0.6% of terminal power per 0.9% of speed deviation — was found by reading Dynawo's Modelica after its trajectory diverged from ours. That is precisely what an external reference is for.
    • EMT, harmonics, reliability and protection coordination stay genuinely far off. Each needs modelling gridoxide has no foundation for — sub-cycle three-phase stepping, per-frequency Y-buses, outage-rate data, relay curves — and none is currently pointed at by any plan.

    See Resources for the datasets and reference implementations each would be validated against.

  11. Transformer tap control, and the outer-loop layer under it — Done. This entry used to lump both in with "materially larger undertaking or outside gridoxide's current scope", and singled out static taps as a modelling assumption four of the five comparison tools do not make. They are one job, and the second half turned out to be the load-bearing one: newton_raphson_enforcing_q_limits and newton_raphson_distributing_slack were standalone entry points, so a caller could have at most one — not an architecture nicety but two shipped features that could not run together. Both are outerloop::OuterLoop implementations now, driven over an ordered list by powsybl's own schedule, with TransformerVoltageControl and PhaseControl as the third and fourth.

    Two things the fixtures corrected, both recorded as assertions so they cannot quietly stop being true. Svedala's own published solution violates all eleven of its declared deadbands by 2–5%, so the obvious fixed-point gate — taps must not move, since SSH and SV agree — had a false premise, and a loop that left those taps alone would be the defective one. And a discrete tap often cannot reach a deadband at all: PST_PhaseTapChangerLinear_Type2 asks for zero flow within 5e-4 pu on a shifter whose closest position leaves 0.11 pu, which needs an outcome of its own rather than being reported as success.

    Found on the way: CGMES transformer ordering was nondeterministic — ends_by_pt is a HashMap and Rust randomizes its iteration order per process, so every flat branch index derived from the transformer list came out differently on each run of the same program against the same file. Nothing asserted on one, so it never failed.

    Still absent: TCSC/SSC and the wider FACTS set; powsybl's two continuous-then-round voltage strategies; reactivePower-mode tap control; shunt-section control; tap control inside a contingency or batch sweep, and in DC. And the outer-loop layer is deliberately internal — the list is built by the crate, not discovered — so the row above reads ⚠️ rather than ✅. See Outer Loops and Transformer Tap Control.

Note on state estimation

Done in both domains — snapshot and batched, with voltage, power and current sensors, symmetric and per-phase. Within that scope gridoxide matches the most capable reference tool and leads it in two places. What is left of the three gaps this note used to list is one part of one of them, at the end. An earlier version claimed parity outright, which overstated it; a later one left the summary row saying "symmetric only" for some time after the phase domain worked, which understated it in the one place a reader looks first.

se::nr::estimate is Gauss-Newton on the normal equations, validated against power-grid-model's own state-estimation fixtures (committed under tests/data/pgm/state_estimation/ with their MPL-2.0 license files): per-unit magnitudes agree to 1.5e-9 on transmission-case — measured, against a standing assertion of 1e-6, since a gate is a floor and not a claim — and every sparse backend produces the same answer, since the gain matrix is an ordinary square system. See the State Estimation chapter.

Both analyses VeraGrid is credited with above are present. Observability (se::observability::analyze) separates structural from numerical unobservability and names the buses and quantities involved, rather than only reporting that a factorization failed. Bad-data detection (se::bad_data::analyze) runs the chi-squared test and identifies culprits by largest normalized residual. Zero injections are enforced as hard equality constraints rather than as high-weight pseudo-measurements — the approach that avoids the ill-conditioning power-grid-model has two fixtures named after.

Both of power-grid-model's calculation methods are implemented and agree with each other: Newton-Raphson (se::nr) and the prefactorized iterative_linear (se::iterative), selectable per call. link is modelled now (stamped as a branch, see the zero-impedance chapter), so the fixtures using one are reachable. Pseudo-measurement augmentation — filling an unobservable region with forecast values, which VeraGrid does — is not implemented; gridoxide reports the unobservable set instead, which is the prerequisite for it.

The two leads

  • Bad-data detection, which power-grid-model does not have at all. Checked against its own documentation rather than assumed: it reports a per-sensor residual and stops there — no chi-squared test, no identification of a culprit. se::bad_data::analyze does both.
  • Newton-Raphson robustness. power-grid-model's Newton-Raphson estimator raises SparseMatrixError on every benchmark case from 300 buses up, on documents its own iterative-linear method estimates from the same sensors without complaint, and that gridoxide's Newton-Raphson converges on to 1e-14. See scripts/bench/README.md §7.

The gaps that remain

Measured against power-grid-model 1.13 (references/power-grid-model/), in order of how much they matter:

  1. Asymmetric state estimation. Done, and reachable. What remains is narrower than the heading suggests, and is a modelling difference rather than a missing capability.

    A phase-domain document now estimates end to end. SeNetwork::from_3ph builds the measurement model for a 3N-bus network, pgm::pgm_3ph_maps supplies the object-ID maps a sensor needs to resolve against it, and pgm::measurement::measurements_from_pgm_3ph maps sensors onto phase-expanded targets. tests/se_three_phase_test.rs estimates power-grid-model's transmission-case in the phase domain and matches the answer it published for that network solved asymmetrically — all 33 phase-buses to 1e-6, with angles agreeing up to the single rotation nothing measures.

    Nothing in the estimator changed for it. Target, StateLayout, the Jacobian, the constraints, both methods and the batch solver carry over untouched, because a three-phase branch terminal is a six-coefficient CurrentFunctional where a scalar one has two. A branch is indexed 3·branch + phase to match the 3·node + phase bus convention, which is what keeps Target identical between the two domains.

    That carry-over is now gated rather than asserted. It was true, but it had been a claim about function signatures — observability, bad-data detection and the batch solver take the same arguments in either domain, so they ought to work — and the reference handling is exactly where a symmetric assumption would have hidden, since three per-phase rotations are three symmetries where StateLayout removes one. tests/se_three_phase_test.rs now checks each of them in the phase domain: observability finds every physical bus determined and only synthesized ones not; bad-data detection names the corrupted phase by a factor of five over the runner-up; and a batch of five scenarios matches a sequential loop bit for bit.

    Two things came out of writing those gates. pgm::se::se_case_from_pgm_3ph is the single entry point, because standing the phase-domain model up took eight calls in a particular order against four converters and three stamping functions — which is why the estimator worked for some time with nothing outside the test suite able to call it. gridoxide estimate --asymmetric is that door, as is StateEstimationModel.from_pgm_json(..., asymmetric=True) — the same class, with solve, solve_batch, observability and bad_data unchanged, since none of them knows which domain it is in. On a balanced document the two domains agree per phase to 1e-6, which is a cross-check neither domain could perform alone.

    And Gauss-Newton has a divergence cliff, in both domains. It takes an undamped step, so a measurement far enough from consistent throws the first one past the basin: on transmission-case the phase domain converges at a 15σ error and diverges at 20σ, the symmetric problem converges at 20σ and diverges at 50σ. It reports MaxIterations rather than a converged-looking answer, which is the part that matters — a diverged state carries residuals of 1e17, and a bad-data report built on one is nonsense that looks like a diagnosis. A line search would raise the cliff; there is none, and the behaviour is gated so that adding one will announce itself.

    The symmetric sensors that fixture carries need no conversion beyond a rotation, and it is worth recording why: a voltage reading is line-to-line over u_rated in the scalar case and line-to-neutral over u_rated/√3 here, which is the same number for a balanced set, and a power reading is a three-phase total over s_base against a per-phase value over s_base/3, likewise. So the value replicates and only the angle rotates by 0/−120/+120 — which is exactly power-grid-model's own ComplexValue<asymmetric_t> broadcast.

    Asymmetric sensors describe their three phases separately here rather than reducing to the symmetric problem: asym_voltage_sensor, asym_power_sensor and asym_current_sensor each select their own phase's reading, against a line-to-neutral voltage base and a s_base/3 power base. Checked against single-node-source-asym-voltage-sensor, where the sensor determines the answer outright and power-grid-model reports back exactly what it read.

    What is left is one modelling difference, and it is worth stating precisely. Voltage magnitudes alone do not determine a phase relationship. With flows in the set the source impedance couples the phases — a flow through it depends on all six of its phasors — but given only magnitudes the three per-phase rotations are three separate symmetries where StateLayout removes one, and the gain matrix is correctly singular. power-grid-model answers such a case because its source is a boundary condition, a fixed balanced three-phase voltage; gridoxide's is an unknown behind a synthesized impedance, the same difference that leaves SeReport::unconstrained naming a virtual bus per source on the symmetric side.

    The obvious fix is wrong, and it was tried rather than assumed. gridoxide builds that virtual bus balanced, so constraining its three angles to differ by ±120° looks like free information and removes exactly the two directions in question. It also contradicts the data: power-grid-model's own single-node-source-asym-voltage-sensor reads three phases whose sequence angles are 0.1, 0.2 and 0.3, on a node with no appliance — zero injection, so zero current through the source branch, so V_virtual = V_node exactly. The virtual bus is as unbalanced as the measurement says the node is, and the constraint moves that fixture's answer from 0.1 to 0.2. The balance is a property of the initial state gridoxide synthesizes, not of the equivalent it represents.

    So this is not a missing feature but a real limit: those two directions are undetermined, and reporting singular is the correct answer. power-grid-model answers instead because it has no source-internal bus to be undetermined about. Both directions are asserted in tests/se_three_phase_test.rs, so a change that supplies them will announce itself.

    Nothing is refused now, where four components used to be, and pgm_3ph_maps no longer returns a Result — it could not fail, and saying otherwise in a signature is its own kind of wrong. Every committed fixture builds in both domains.

    The winding-pair restriction went when transformer_seq_params grew power-grid-model's general zero-sequence algorithm. voltage_regulator is now ignored rather than refused: these maps serve state estimation and nothing else, and a regulator pins a generator's bus and lets Q float — a boundary condition for a power flow, meaningless where every bus voltage is an unknown to be recovered. Refusing it made an estimable document inestimable for a reason that could not have changed the estimate, and inconsistently, since the symmetric path read the same document without comment. And link is now modelled: an ideal connection is phase-transparent, so its sequence parameters are one branch_calc_param repeated — which is literally what power-grid-model's own link.hpp computes, calc_param_y_asym(y_link, 0.0, y_link, 0.0, 1.0). The short-circuit path had modelled it that way all along and carried its own copy of the loop; there is one now, shared, so the two cannot disagree about what a link is. Six committed state-estimation fixtures use a link and none of them could be estimated in the phase domain before.

    Two things came with it. A current sensor on a link is refused in the phase domain, as it already was symmetrically and as power-grid-model refuses it — a link's admittance is a chosen regularization constant, so the current through one is an artifact of that choice. And the Kirchhoff gate that checks the terminal functionals against the Y-bus now runs over a link-bearing document, with the link's admittance pinned separately against LINK_Y and against coupling no phase to any other — because Kirchhoff compares two descriptions built from the same blocks and would pass a link that was consistently wrong.

    And three_winding_transformer is modelled, which was the last of the four and the only one that was ever a real gap. It is three two-winding legs to a synthesized star node — the same star equivalent the symmetric path uses, three_winding_legs now assembling it for both so they cannot diverge — plus the star node itself, which is three buses like any other and injects nothing. What the phase domain adds is each leg's winding configuration, which the symmetric model never had to carry: T1 is wye_n/wye_n at clock 0 whatever winding_1 is, while T2 and T3 carry their own winding against winding_1 at a reversed clock, exactly as three_winding_transformer.hpp::convert_to_two_winding_transformers specifies. PgmThreeWindingTransformer grew winding_1/_2/_3 fields to hold them; nothing had read them before.

    Gated against power-grid-model's own asymmetric answer for a three-winding transformer over ten tap positions — the only such reference in the vendored corpus, newly committed — and, for state estimation, against its published magnitudes for the fixture whose three power sensors sit on the transformer's three sides, which also exercises resolving a side to its leg.

    What that reference cannot gate is worth stating, because the obvious reading of it is wrong. Its network is a source and a transformer with no load, so the excitation is balanced and no zero-sequence current flows in it at all — the winding configurations, which are the whole difficulty, are invisible to it. Verified rather than assumed: swapping the two sides of legs T2 and T3 leaves every number in that comparison unchanged. So the zero-sequence structure is asserted separately and directly, and that check does fail under the swap.

  2. Current sensors. Done. sym_current_sensor and asym_current_sensor are read in both angle frames, on both calculation methods, checked against power-grid-model's own global-current-sensor and local-current-sensor fixtures — which are identical but for the frame and converge to visibly different states, so the distinction is genuinely exercised rather than nominally supported.

    Stored decomposed into real and imaginary components rather than as a magnitude and an angle, following power-grid-model, and for a decisive reason of gridoxide's own: arg(I) has a branch cut and gridoxide has no phase_mod_2pi anywhere, so a polar residual taken near ±π would silently chase a 2π error. |I| also has an unbounded derivative on an unloaded branch. The variance decomposition reproduces power-grid-model's second-order formula exactly.

    Two rules are enforced that power-grid-model checks only in its Python validation layer, its C++ core accepting and double-counting the mixture: a power sensor and a current sensor may not share a terminal, and two current sensors on one terminal may not disagree about the frame. A current sensor on a link is refused outright — a link's admittance is a regularization constant, so the current through one is an artifact of that choice rather than a measurement.

    One divergence worth recording: power-grid-model refuses to run at all when a global-angle sensor has no voltage angle to reference, raising NotObservableError. gridoxide reports it through ObservabilityReport::global_current_without_angle_reference instead. The state is fully determined there — determined to the wrong reference, since StateLayout pins a bus the sensor contradicts — so calling it unobservable would misname it.

  3. Batch state estimation. Done. se::batch::SeBatchSolver (crates/gridoxide-core/src/se/batch.rs) estimates many scenarios over one topology and measurement structure, parallel across cores, each worker amortizing one symbolic factorization — the same shape batch::BatchSolver has for power flow, and exactly what PersistentEstimator's already-written cache-validity condition allows. MeasurementOverride varies values and sigmas and refuses to vary kind or target, mirroring BusOverride's refusal to change bus_type. Exposed as StateEstimationModel.solve_batch(scenarios, threads). Checked against power-grid-model's own sensor-update-* and unbalanced-power-measurements-* batch fixtures, and asserted bit-for-bit identical to a sequential loop at every thread count.

On speed, the iterative-linear method runs 1.6-2.0x behind power-grid-model's across an order of magnitude of problem size (scripts/bench/README.md §7). Measured rather than inferred, that is entirely an iteration-count gap: gridoxide's own iterations are 30-40% cheaper than power-grid-model's and it takes about three times as many, and undamped its map does not converge at all. See docs/src/state_estimation/iterative.md.

A fourth gap surfaced while closing the third and is now closed too: de-energized islands. power-grid-model reports a node in a component containing no source as energized: 0 with a state of exactly zero — topology decides it, and a voltage sensor on such a node is simply ignored. gridoxide had no equivalent, and the consequence was not a wrong answer but no answer: jacobian::mask_untouched pins a column nothing structurally touches, which catches a fully isolated node, but a de-energized node reached by a zero-injection constraint or by its own sensor is touched and undetermined, so the gain matrix came back singular. SeNetwork::energized now carries the same topological verdict solver::PersistentSolver has always applied on the power-flow side (network::connected_components + mark_unreferenced_islands): such a bus contributes no rows and no constraints, and is reported at zero.

Two smaller gaps have closed. A sensor on a three-winding transformer side (measured_terminal_type 6/7/8) used to return MeasurementError::UnsupportedTerminalType; it now maps to the corresponding leg's From terminal, since a three-winding transformer is already resolved into three two-winding branches around a star bus. And the estimator no longer starts flat: se::nr::linear_start carries the network's structural phase shifts, without which Gauss-Newton converges to a different stationary point on any network containing a phase-shifting transformer — reporting success, with an objective nine orders of magnitude worse than the true optimum.

One caveat on all of the above that is about evidence rather than features. Bad-data detection is exercised on deliberate single gross errors — bad_data's own a_gross_error_is_detected_and_identified, and, on a real fixture in the phase domain, bad_data_detection_finds_the_corrupted_measurement, which perturbs one phase of one node by fifteen sigmas and requires the culprit to rank first by a factor of five over the runner-up, with a_clean_phase_domain_estimate_is_not_rejected as its other half. That is a real check and a narrow one.

What is still missing is realistic noise: every benchmark and fixture here estimates from data that is perfectly consistent apart from an injected error, so nothing measures how the detector behaves against a plausible measurement set — several small errors at once, correlated errors, or a gross error on a barely-redundant measurement. Nor is lead 1 comparative: power-grid-model has no bad-data detection to compare against, so "better" here means "present". A noise-generating harness does not exist.

Note on realistic benchmark coverage

gridoxide.matpower (python/gridoxide/matpower.py — the conversion logic moved into the pip package itself; scripts/bench/matpower_to_pgm.py is now only a thin CLI wrapper around it) populates voltage_regulator.q_min/q_max from MATPOWER's gen matrix Qmax/Qmin columns (summed across every active gen at a bus, matching how p_specified is already summed). Confirmed against all 12 real benchmark cases: 11 of them have at least one PV bus whose unconstrained Q genuinely exceeds its nameplate limit (from 4 violations on the smallest case to 166 on case3120sp), and outerloop::ReactiveLimits converges on every one of them, including cases needing dozens of simultaneous PV→PQ switches across several outer iterations. MATPOWER represents "no limit" as literal +-Inf on some real cases (e.g. case9241pegase) — the converter omits the key entirely in that case rather than writing a non-standard Infinity JSON token, matching PGM's own "unset means unbounded" convention exactly.

Power Systems Test Datasets & Tools

A synthesized reference of open datasets and tools, compiled from research into candidate sources for gridoxide/cimoxide testing and validation.

Transmission-level test cases (steady-state / power flow)

SourceWhat it offersNotes
Sienna-Platform/PowerSystemsTestData5-Bus PJM, 5-Bus-hydro, 118-Bus PCM (high renewables), TAMU ACTIVSg synthetic grids (10k-bus WECC, 70k-bus EI, 2000-bus ERCOT), Matpower casesPlain case files, not tied to Julia structs — good for parser testing
Sienna-Platform/PowerSystemCaseBuilder.jl200+ cases pre-built into PowerSystems.jl data modelJulia-native; skips parsing step if you're in that ecosystem
SanPen/VeraGrid — Grids_and_profiles/gridsIEEE 39/118/30-bus, Pegase 2869/1354-bus, RTE 1951-bus, PGLib cases, HVDC test cases (2/10/12-bus AC/DC)Reads Matpower natively; solves all Matpower 8 cases incl. continental-USA scale in ~1s
SanPen/VeraGrid — src/tests/StateEstimationReference SE test cases with expected outputsDirectly relevant to DSSE validation
power-grid-lib/pglib-opfCurated AC-OPF benchmark cases (incl. TAMU 200/500/2000-bus, ARPA-E GO Competition cases)Maintained by IEEE PES Task Force; reference results included; supersedes NESTA
power-grid-lib/pglib-opf-hvdcOPF benchmark cases with HVDC lines (MatACDC format)Reference impl. in PowerModelsACDC.jl

Distribution-level / DSSE-relevant test cases

SourceWhat it offersNotes
e2nIEE/simbenchGerman research-project MV/LV benchmark networks with full time seriesMost relevant to your MV DSSE work; some grids underdetermined for SE out-of-box (need synthesized pseudo-measurements)
e2nIEE/pandapowerMatpower/PYPOWER-compatible cases, plus own SE moduleUseful for cross-validating your SE implementation
IEEE/EPRI distribution feeders (4/13/34/37/123-bus, 8500-node)Classic unbalanced 3-phase distribution benchmarksDistributed via OpenDSS/EPRI, mirrored in various repos (e.g. dss-extensions org) rather than one clean source

CGMES / CIM data

SourceWhat it offersNotes
powsybl-core — cgmes-conformity moduleENTSO-E MicroGrid, SmallGrid, MiniGrid CGMES conformity test configs (v2.0)Industry-standard CGMES validation suite; extract cgmes-conformity/src/main/resources, no need to build the Java project
powsybl/powsybl-cgmes-conformity-assessmentsTracks PowSyBl's own conformity assessment resultsReference for how a mature parser scores

Realistic large-scale regional network models

SourceWhat it offersNotes
PyPSA/pypsa-eurFull ENTSO-E-area European transmission network, generator fleet, sector linksBuilt via Snakemake from open data (Eurostat, JRC-IDEES); realistic scale, not a "test case"
PyPSA/pypsa-usaUS regional network modelSame pattern as pypsa-eur
PyPSA/pypsa-zaSouth Africa network modelRequires separate .7z data bundle download
PyPSA/technology-dataCost/efficiency assumptions by technology and yearInput data for OPF-type work, not topology
PyPSA/PyPSA — examples/Small toy AC/DC networksGood for quick unit tests

Dynamic simulation (RMS / DAE)

SourceWhat it offersNotes
dynawo/dynawoValidated example cases for DynaFlow, DynaWaltz, DynaSwingRTE-maintained, MPL-2.0; roadmap includes Nordic32 and IEEE dynamic cases
dynawo/dynawo-algorithmsContingency analysis, voltage margin, load-increase-to-collapseTool, not raw data
dynawo/dynawo-large-scale-validationValidation framework vs. legacy simulators (Astre, Hades), extensive contingency runs + Jupyter comparison pipelineMethodology reference for validating your own RMS/DAE solver
dynawo/dyn-grid-compliance-verificationGrid-code compliance verification for generators/IBR (fault-ride-through etc.)Relevant to IBR/GFM converter work
dynawo/IEC-WECC-GenericModels-ComparisonWT Type 4 generic model comparison casesWind turbine dynamics specifically

Tools

Open-source power systems frameworks (readers/solvers)

ToolLanguageRelevance
PowSyBl (powsybl-core)JavaStrongest open-source CGMES compatibility (per FullGrid evaluation); IIDM network format; CGMES conformity data bundled in
pandapowerPythonMatpower/PYPOWER-compatible, includes SE module
PyPSAPythonSector-coupled energy system modeling; large realistic regional models via pypsa-eur/usa/za
VeraGridPythonReads Matpower, PSS/E RAW; has SE test infrastructure; fast large-case AC PF
DynawoModelica/C++RMS dynamic simulation (DAE, IDA/SUNDIALS-based); reads IIDM
PowerSystems.jl / PowerSimulations.jl (Sienna)JuliaNREL-origin; paired with PowerSystemCaseBuilder.jl

MADSuite — nonlinear optimization solvers and modeling (GPU-oriented)

madsuite-org (madsuite.org) is the umbrella org for the MadNLP/ExaModels stack — a Julia-centric but increasingly language-agnostic set of NLP/LP solvers, algebraic modeling with AD, and KKT/linear-algebra backends, with GPU support as the organizing theme. Directly relevant to gridoxide's OPF work: it is the closest open-source analogue to the interior-point + sparse-KKT machinery in the OPF solver, and a source of reference results to check against.

Solvers

RepoLanguageRelevance
MadNLP.jlJuliaFilter line-search interior-point NLP solver (an IPOPT analogue) with GPU support; modular KKT and linear-solver layers. Reference point for gridoxide's own IPM
MadIPM.jlJuliaInterior-point solver for linear programs, GPU-capable
MadNCL.jlJuliaMadNLP extension implementing Algorithm NCL; targets infeasible or degenerate NLPs — the failure mode AC-OPF hits on stressed cases
CCOpt.jlJuliaSolver for Mathematical Programs with Complementarity Constraints (MPCCs) — the natural formulation for complementarity-style limit switching (e.g. PV→PQ, tap limits)
HybridKKT.jlJuliaGolub & Greif condensed-KKT solver inside MadNLP; the "how do I factorize an indefinite KKT system on a GPU" answer
BlockDSS.jlJuliaBlock-structured (esp. block-tridiagonal) linear solvers, CPU / CUDA / ROCm, Float32 and Float64, sequential and batched variants
MadDiff.jlJuliaForward/reverse implicit differentiation through MadNLP/MadIPM/HybridKKT KKT systems — i.e. sensitivities of an optimum w.r.t. parameters. Conceptually adjacent to gridoxide's sensitivity work, but at the OPF level. Explicitly WIP (requires dependency forks)
MadCore.jlJuliaShared core for the Mad* solvers; no README yet
MadNLPGraph.jlJuliaGraph/structured-decomposition extension for MadNLP; dormant since 2021, MPL-2.0

Modeling and AD

RepoLanguageRelevance
ExaModels.jlJuliaSIMD-abstracted algebraic modeling + automatic differentiation targeting GPUs; the modeling front-end MadNLP is usually paired with
ExaModelsPower.jlJuliaAC-OPF (and multi-period / storage) models built on ExaModels — the most directly comparable artifact to gridoxide's OPF formulations
ExaPowerIO.jlJuliaMinimal Matpower-format reader, deliberately narrower than PowerModels.jl. Useful as a second opinion on Matpower parsing edge cases
ExaModelsAMPL.jlJuliaWrites ExaModels models out to AMPL .nl — a bridge for feeding models to any .nl-reading solver
ExaModelsExamples.jlJuliaWorked ExaModels examples
DynamicNLPModels.jlJuliaNLPModels for dynamic optimization / optimal control (MPC-shaped problems)
MPCCModels.jlJuliaNLPModels.jl extension for MPCCs; the modeling side of CCOpt.jl

Cross-language interop (Rust-relevant)

The part of the org most useful to a non-Julia project: a plain-C ABI for handing nonlinear programs across language boundaries, with both Julia and Python consumers. It offers a path to expose a gridoxide-built OPF model to MadNLP (or any NLPModels-compatible solver) without either side embedding the other's runtime.

RepoLanguageRelevance
cnlp-abiCThe cnlp.h specification: one shared library carries N models, each publishing objective, gradient, constraints, sparse Jacobian and Lagrangian Hessian behind plain C symbols. Evaluation semantics are NLPModels.jl's, transliterated to C. A Rust cdylib can implement this directly
CNLPModels.jlJuliaLoads a cnlp shared library as an NLPModels.AbstractNLPModel, so any Julia solver can solve it
cnlpmodels-pyPythonSame, for Python — ctypes + numpy, no extra runtime; works with cyipopt and scipy.optimize
examodels-pyPythonPython interface to ExaModels.jl (SIMD-parallel modeling + AD, CPU and GPU)
libMadJulia/CMakeMetapackage compiling MadNLP (and CCOpt) into a shared library with a C interface, shipped as standalone release tarballs; loadable as a CasADi ≥3.8 plugin. WIP, but the route to calling MadNLP from Rust

Benchmark sets

RepoLanguageRelevance
COPSBenchmark.jlJuliaCOPS benchmark suite implemented in JuMP — standard NLP solver stress tests
LuksanVlcekBenchmark.jlJuliaLukšan–Vlček nonlinear test problems
MadNLPBenchmark.jlJuliaMadNLP's own benchmark harness
MPCCbenchmark.jlJuliaMPCC benchmark problems
MIPLIB.jlJuliaMIPLIB instance access
ExaModelsPower-benchmarking-archiveMATLABFrozen (2025-10-07) OPF benchmarking data + scripts from ExaModelsPower.jl — published numbers to compare gridoxide OPF timings against

Non-code repos in the org (site, logos, and talk/workshop material — madsuite-org.github.io, madsuite-logos, ifac2026, ifac2026-workshop, neurips2025-mathprog-on-gpu, slides-informs-2025, powertech2025) are omitted here, though the workshop and slide repos are worth a look for the current state of GPU-accelerated OPF.

General power-systems data/format tooling encountered

  • Matpower / PYPOWER case format — widely supported de facto standard; pandapower, VeraGrid, pglib-opf, PowSyBl-adjacent tools all read it.
  • PSS/E RAW format — readable by VeraGrid; common utility/vendor export format worth supporting alongside CGMES.
  • UCTE-DEF — potential fallback path if CGMES export isn't available from a data source.

Books and normative references

Unlike the rest of this file, this section is not verified against a local checkout — it is compiled from general knowledge of the literature, so editions and years may have moved. Titles and authors are the load-bearing part; treat the year as a hint. Entries are grouped by the chapter of this book they support, and each says why it is the one to reach for rather than simply existing.

Power flow and general analysis

WorkWhy
Kundur, Power System Stability and Control (McGraw-Hill, 1994; 2nd ed. with Malik, 2022)The single most-cited text in the field. Bought for the dynamics half, kept for its modelling chapters — the machine, exciter, governor and load models every other topic assumes
Milano, Power System Modelling and Scripting (Springer, 2010)The closest published thing to what src/ actually is: how to implement power flow, continuation, and the DAE formulation, with the Jacobian structure written out rather than waved at. The PSAT author. If one book on this list is worth reading cover to cover for gridoxide's purposes, it is this one
Grainger & Stevenson, Power System Analysis (McGraw-Hill, 1994)The standard undergraduate treatment; per-unit, symmetrical components and the classical fault analysis, clearly
Expósito, Conejo & Cañizares (eds.), Electric Energy Systems: Analysis and Operation (CRC, 2nd ed. 2018)The best modern single-volume survey — a chapter each by people who work on that chapter's subject
Stott, Jardim & Alsaç, DC Power Flow Revisited, IEEE Trans. Power Systems 24(3), 2009Not a book, but the reference for linear::btheta and the reason the summary table distinguishes two DC approximation variants
Zimmerman, Murillo-Sánchez & Thomas, MATPOWER, IEEE Trans. Power Systems 26(1), 2011, plus the MATPOWER User's ManualThe manual is an unusually honest specification of the algorithms, not just a usage guide — worth reading as a spec when a convention is ambiguous

Optimal power flow and optimization

WorkWhy
Wood, Wollenberg & Sheblé, Power Generation, Operation, and Control (Wiley, 3rd ed. 2013)The classic for economic dispatch, OPF, unit commitment and state estimation in one volume. The reference for the OPF extensions not implemented here
Nocedal & Wright, Numerical Optimization (Springer, 2nd ed. 2006)The method book behind opf::nlp — interior point, line search, filters, and the trust-region alternatives. Where to go when the barrier update or the regularization misbehaves rather than the model
Boyd & Vandenberghe, Convex Optimization (Cambridge, 2004; free PDF)For the DC-OPF side: why the QP is convex, what the KKT certificate means, and duality as the source of locational marginal prices
Kirschen & Strbac, Fundamentals of Power System Economics (Wiley, 2nd ed. 2018)What the prices the OPF reports actually are, and why a congested network produces different ones per bus
Conejo & Baringo, Power System Operations (Springer, 2018)Security-constrained dispatch and the operational framing RAO sits inside

State estimation

WorkWhy
Abur & Expósito, Power System State Estimation: Theory and Implementation (CRC, 2004)The reference, without competition. WLS, observability, bad-data detection by normalized residual, and the constrained formulation for zero injections — which is crates/gridoxide-core/src/se/ chapter by chapter
Monticelli, State Estimation in Electric Power Systems: A Generalized Approach (Kluwer, 1999)The generalized/Hachtel formulation, and the deeper treatment of network observability
Kersting, Distribution System Modeling and Analysis (CRC, 4th ed. 2017)The prerequisite for the unbalanced distribution SE the summary table flags as the binding gap — untransposed lines, phasing, and why the symmetric reduction stops being valid

Short circuit and protection

WorkWhy
IEC 60909-0 (Short-circuit currents in three-phase a.c. systems)The normative document crates/gridoxide-core/src/shortcircuit/ implements. There is no substitute: the \(c\)-factors, the equivalent voltage source and the impedance corrections are definitions, not derivations
Anderson, Analysis of Faulted Power Systems (IEEE Press, 1995)The classical treatment of symmetrical components and fault boundary conditions — the derivation IEC 60909 assumes you already know
Das, Power System Analysis: Short-Circuit Load Flow and Harmonics (CRC, 3rd ed. 2018)Covers two of the rows in the summary table at once, and is unusually practical about what the standards leave to judgement
Blackburn & Domin, Protective Relaying: Principles and Applications (CRC, 4th ed. 2014)The starting point for the protection-coordination row, which currently has no foundation in src/ at all

Dynamics: RMS, EMT and small-signal

WorkWhy
Sauer, Pai & Chow, Power System Dynamics and Stability (Wiley-IEEE, 2nd ed. 2017)The cleanest development of the DAE model and the time-scale separation that makes RMS simulation legitimate. The right first book for the RMS row
Machowski, Lubosny, Bialek & Bumby, Power System Dynamics: Stability and Control (Wiley, 3rd ed. 2020)Broader and more modern than Kundur on control and renewables, and better on why a phenomenon happens before the equations arrive
Milano, Dassios, Liu & Tzounas, Eigenvalue Problems in Power Systems (CRC, 2020)The small-signal/modal row: linearizing the DAE, the generalized eigenproblem, participation factors, and the numerics of doing it at scale
Watson & Arrillaga, Power Systems Electromagnetic Transients Simulation (IET, 2003)The EMT row: Dommel's trapezoidal companion-circuit method, travelling-wave lines, and switching
Dommel, EMTP Theory Book (BPA, 1986)The foundational EMT document. Every EMT tool is a descendant of it, and it is written as a specification

Voltage stability

WorkWhy
Van Cutsem & Vournas, Voltage Stability of Electric Power Systems (Kluwer, 1998)The definitive treatment, and careful about the distinction between the static loadability limit and the dynamic collapse mechanism
Ajjarapu, Computational Techniques for Voltage Stability Assessment and Control (Springer, 2006)Continuation power flow specifically — predictor, corrector, parametrization and the behaviour at the nose. The method book for the cheapest of the missing analysis types
Taylor, Power System Voltage Stability (McGraw-Hill, 1994)The operational view: what actually collapses, and what operators do about it

Harmonics and power quality

WorkWhy
Arrillaga & Watson, Power System Harmonics (Wiley, 2nd ed. 2003)The harmonics row: per-frequency network models, source characterization and penetration studies

Reliability and adequacy

WorkWhy
Billinton & Allan, Reliability Evaluation of Power Systems (Plenum, 2nd ed. 1996)Where LOLE, EENS and the rest are defined, and the Monte Carlo and analytical machinery for computing them

Sparse numerics

WorkWhy
Davis, Direct Methods for Sparse Linear Systems (SIAM, 2006)By KLU's own author, and the book crates/gridoxide-sparse/src/klu_native/ is a translation of the ideas from. AMD ordering, BTF, the symbolic/numeric split, and why factorization reuse is the whole game for repeated solves
Davis & Palamadai Natarajan, Algorithm 907: KLU, a Direct Sparse Solver for Circuit Simulation Problems, ACM TOMS 37(3), 2010The paper for the specific solver; short, and the closest thing to a specification
Duff, Erisman & Reid, Direct Methods for Sparse Matrices (Oxford, 2nd ed. 2017)The broader reference — pivoting strategies, fill-reducing orderings and the block methods
Tinney & Walker, Direct Solutions of Sparse Network Equations by Optimally Ordered Triangular Factorization, Proc. IEEE 55(11), 1967The paper that made large-scale power flow possible, and still the clearest statement of why ordering matters more than arithmetic

Remedial actions, security and capacity calculation

There is no textbook for this one, which is worth knowing before looking for it. The subject is defined by regulation and by implementation rather than by a literature: the ENTSO-E CACM Regulation and its capacity-calculation methodologies, the CORE and Nordic CCR methodology documents, and OpenRAO's own documentation — which is the reference gridoxide's remedial action optimization is gated against. Kirschen & Strbac and Conejo & Baringo above give the economic and operational framing; the mechanics come from the methodologies.


Practical recommendations

  • DSSE/MV work → simbench (primary), VeraGrid's StateEstimation folder (reference cases), IEEE/EPRI distribution feeders (unbalanced 3-phase)
  • AC-OPF/SCOPF work → pglib-opf (primary benchmark), pglib-opf-hvdc if MTDC scope continues
  • CGMES parser validation (cimoxide) → ENTSO-E conformity suite via powsybl-core, itesla/CGMES for CIM14 legacy coverage
  • Large-scale solver benchmarking → pypsa-eur, VeraGrid's Matpower cases (incl. continental-USA scale)
  • RMS/DAE dynamics validation → Dynawo example cases + dynawo-large-scale-validation methodology
  • Interior-point / OPF solver validation → MadNLP.jl and ExaModelsPower.jl as the reference implementation to diff against; COPSBenchmark.jl for generic NLP robustness; ExaModelsPower-benchmarking-archive for published timings
  • Handing gridoxide models to external solvers → implement the cnlp-abi C ABI from a Rust cdylib, then consume via CNLPModels.jl (Julia) or cnlpmodels-py (Python/cyipopt)
  • Reading, if only one book per topic → Milano (implementation and DAEs), Abur & Expósito (state estimation), Nocedal & Wright (the OPF's interior point), Davis (the sparse solve), Sauer/Pai/Chow (RMS dynamics), Ajjarapu (continuation power flow), IEC 60909-0 (short circuit, normative). See Books and normative references for why each

Benchmarking and Profiling

scripts/bench/README.md is the single source of truth for every benchmark number in this project. This page is a map into it, not a copy of it — the numbers live there, next to the scripts that produce them, so they can be updated in one place when re-measured.

Profiling

For profiling with perf, set:

sysctl kernel.perf_event_paranoid=1

What is measured, and where

scripts/bench/README.md is organized as a numbered sequence of benchmarks:

SectionWhat it covers
§1–3Generating a synthetic radial MV/LV benchmark grid, then timing gridoxide and power-grid-model on it
Interpreting resultsHow to read the numbers, including the cold-vs-warm distinction
§4The 12-case real IEEE/MATPOWER test-case suite, against five other solvers
§4bBatched power flow — the multi-core CPU baseline (batch::BatchSolver)
§4cThe JAX oracle validating the block-diagonal embedding
§4dBlock-diagonal embedding on real sparse code
§5Cross-validating CGMES import against pypowsybl
§6The CGMES conformance test configurations
§7State estimation — both gridoxide methods against both power-grid-model methods
§8DC (Bθ) power flow against the AC solve, and the cost of a PTDF/LODF column
§9AC N-1 contingency screening: factorization reuse across an outage sweep
§10Node-breaker topology import, and what retaining switches costs

The two benchmark shapes

Synthetic radial distribution grid (§1–3). examples/bench_network.rs and scripts/bench/bench_gridoxide_native.py time gridoxide against power-grid-model on generated MV/LV topology at controllable scale. Its cold mode measures N independent flat-start solves with no shared state; the optional warm mode measures repeated solves through a PersistentSolver — see Backends and Factorization Reuse.

Real power-system test cases (§4). Twelve real IEEE/MATPOWER grids, 14 to 9,241 buses, comparing gridoxide against five independent solvers: power-grid-model, lightsim2grid, RTE's powsybl-open-loadflow (via pypowsybl), pandapower's default solver, and VeraGrid.

Two results from that second benchmark are worth stating here because they shaped the code:

  • gridoxide and pandapower's own native path are the only two of the six that converge on all 12 cases. The other four each fail on a subset of the same handful of genuinely hard cases (RTE's own real production grids), confirmed by cross-checking against powsybl-open-loadflow directly — not a gridoxide gap.
  • Compared warm-vs-warm, Klu is frequently faster than lightsim2grid's own KLU-backed C++ solver on this real transmission-topology data, even though PGM still clearly beats every gridoxide backend on the synthetic radial-distribution topology. The comparison genuinely depends on grid topology, not just implementation language.

RMS dynamics

cargo run --release --features dynamics --example dynamics_scale times a ring of alternating generator and load buses, every generator carrying a sixth-order machine with an exciter and a governor — ten differential states per unit on top of the network's two per bus — through a bolted fault and its clearing, at a 5 ms step.

busesunitsunknownsbuild3 s runper stepNewton/step
1681120.7 ms30 ms0.050 ms1.34
64324480.15 ms160 ms0.266 ms1.34
2561281 7920.5 ms682 ms1.14 ms1.34
1 0245127 1682.1 ms3.15 s5.25 ms1.34
4 0962 04828 6729.2 ms16.4 s27.3 ms1.34

Two things are worth reading off this.

Time per step is very close to linear in the system size — 256× the unknowns costs 546× the step, an exponent of 1.14. That is what the formulation was chosen for: the network block is the constant real form of the Y-bus, every device stamp is local, and the pattern is analyzed once for the whole run because every event is value-only. Nothing re-analyzes, whatever happens.

Newton iterations per step are flat at 1.34 across four orders of magnitude. A step normally converges on the first correction and occasionally needs a second; the count does not grow with the system, which is the signature of an exact analytic Jacobian rather than an approximated one.

The build column is the power flow plus initialization, and it is negligible against the run — which is the right shape, since a study sweeps many scenarios over one build.

Provenance and Licensing

gridoxide's own code is licensed under Apache-2.0 (LICENSE). Several pieces of third-party code are vendored, translated, or linked, and each carries its own terms. This page is the summary; the authoritative per-file detail lives in the PROVENANCE.md files kept alongside the code they describe.

The crate-wide license field

Cargo.toml's license field is:

Apache-2.0 AND BSD-3-Clause AND LGPL-2.1-or-later

That is accurate for every default cargo build, not just an opt-in one — because crates/gridoxide-sparse/src/klu_native/ is always built, with no feature gate.

Always built: crates/gridoxide-sparse/src/klu_native/

crates/gridoxide-sparse/src/klu_native/ is a from-scratch Rust translation of vendored SuiteSparse AMD, BTF, and KLU C source. A close translation of licensed source is reasonably a derivative work regardless of implementation language, so it carries forward its upstream license — and that is not one license here:

Upstream packageLicenseTranslated into
AMDBSD-3-Clauseamd/aat.rs, amd/core.rs, amd/postorder.rs, amd/mod.rs
BTFLGPL-2.1-or-laterbtf/maxtrans.rs, btf/strongcomp.rs, btf/mod.rs
KLULGPL-2.1-or-lateranalyze.rs, kernel.rs, factor.rs, scale.rs, refactor.rs, solve.rs

crates/gridoxide-sparse/src/klu_native/PROVENANCE.md has the exact file-by-file mapping back to the upstream C, including which specific functions each Rust file was ported from and how shared header material was classified. See Inside KLU for what the ported algorithm actually does.

Opt-in: --features klu

Building with cargo build --features klu additionally compiles the vendored SuiteSparse C itself into the binary via FFI. crates/gridoxide-sparse/vendor/suitesparse/ is a partial vendoring of SuiteSparse at tag v7.12.2 (commit 42151688813c45846a597edcb601435a0e38f3dd, 2026-02-10) — only the Source/ and Include/ subdirectories of five packages (SuiteSparse_config, AMD, COLAMD, BTF, KLU), each keeping its own Doc/License.txt:

PackageLicense
AMDBSD-3-Clause
COLAMDBSD-3-Clause
BTFLGPL-2.1-or-later
KLULGPL-2.1-or-later
SuiteSparse_configBSD-3-Clause

This adds no license beyond what is already listed above, but it does add LGPL's relinking obligations for anyone distributing a binary built with that feature. The klu-dynamic sub-feature exists for that case: it links a system-installed libklu.so instead of statically linking the vendored copy. See crates/gridoxide-sparse/vendor/suitesparse/PROVENANCE.md for exactly what was and was not vendored, and how to update to a newer SuiteSparse release.

Opt-in: --features pardiso

A separate case from all of the above. It dynamically links a locally-installed Intel oneMKL (libmkl_rt.so) at build and run time, under Intel's own Simplified Software License — not LGPL, not OSS, and not vendored or redistributed by this repo in any form. No MKL header or source is copied in; bindgen only reads the local install's own mkl_pardiso.h at build time to generate FFI bindings.

Because nothing MKL-derived is ever copied into or shipped by this crate, Cargo.toml's license field does not change for this feature. Anyone who builds with --features pardiso and distributes the resulting binary is responsible for their own compliance with Intel's oneMKL redistribution terms — this project doesn't audit that on their behalf.

Opt-in: --features cgmes — the cimoxide dependency

The optional cgmes feature (src/cgmes.rs) depends on cimoxide's model crate, a separate Rust project by the same author providing CGMES RDF/XML decoding. Each decoded object is an Element — its class, its mRID and its fields as written, read by Class.attr key — checked against class tables generated from ENTSO-E's schemas.

cimoxide is Apache-2.0, matching gridoxide's own license — no licensing mismatch from this dependency.

The package = rename

cimmodel = { package = "cimoxide-model", version = "0.6.0", optional = true }

The crate's library is named cimmodel, so the code says cimmodel::CimDataset whatever the package is called; package = makes the dependency's name match it.

Formerly typed structs

Up to 0.3, cimoxide published cimoxide-decoder and cimoxide-structs: one generated Rust struct per CIM class, read by field (line.r). 0.4 merged them into cimoxide-model and replaced the structs with Elements read by key (line.get_f64("ACLineSegment.r")). A key names the class that declares the attribute, so a breaker's open is Switch.open. The compiler no longer checks a key: a misspelt one reads as absent, the same as an attribute the file leaves out. The CGMES tests are what catch one.

Formerly a git dependency

Until cimoxide's crates.io release, its crates were a git dependency pinned to a vendor/gridoxide branch — main plus one commit force-adding the code generator's normally-gitignored output, since the generated source is produced by cargo run -p cimgen from ENTSO-E's RDF/SHACL schemas and so did not exist on a fresh main clone.

Updating

To pick up a newer cimoxide schema or generator change:

  1. Release the new version from the cimoxide repo (make generate, then make build && make test before publishing — the generated class tables are what a schema change moves).
  2. Bump the version = value in this repo's Cargo.toml for the cimmodel dependency, and update the version noted above.
  3. Run cargo build --features cgmes and cargo test --features cgmes to confirm the converter still matches tests/cgmes_microgrid_be_test.rs's expectations — CGMES attribute names could change between schema versions, and a renamed one compiles and reads as absent. The fixture-backed CGMES tests silently skip when tests/data/CGMES-Test-Configurations isn't checked out, so git submodule update --init tests/data/CGMES-Test-Configurations first, or the run proves nothing beyond "it compiles".

Test fixtures

The CGMES conformance fixtures under tests/data/cgmes/ are referenced via a git submodule rather than committed, because of their own licensing — see tests/data/cgmes/README.md.

The UCTE, IIDM and CRAC files the importer and remedial-action tests read, and the Cucumber suite of the external gate, come from powsybl-open-rao and carry its MPL-2.0 licence. They are not in this repository. They live in the benchmark-grids submodule under powsybl-open-rao/, pinned to an upstream commit, with that repository's PROVENANCE.md giving the upstream path of each part. tests/data/openrao-fixtures.txt names the files the tests use outside the gate, and tests/data/rao/*.txt select the gate's scenarios by id.

What this repository does keep is its own work: those scenario lists, and the pypowsybl reference solutions under tests/data/ucte/ and tests/data/iidm/, which gridoxide generated with scripts/bench/{ucte,iidm}_reference.py. MPL-2.0 is a per-file copyleft and does not reach the code that reads the files, so Cargo.toml's license field is unaffected either way.

Worth separating from that: src/ucte.rs itself is not a translation of powsybl-open-rao or of powsybl-core's ucte-network module. The column positions and the tap-changer formulae in it were established from the format specification and then verified against those implementations — the .pypowsybl.json files record the verification. Reading a reference to check an answer is not the same as transcribing it, and only the latter would carry the licence across. The same distinction applies to crates/gridoxide-sparse/src/klu_native/, which is a translation and is licensed accordingly above.