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 gridoxideand 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.
| Command | What 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 activesource) and branch count (lines first, then transformers — the index every branch-keyed vector below uses).model.solve()— runs whichevermethodthe model was built with; raisesRuntimeErrorif it doesn't converge withinmax_iteriterations, or if a direct method's matrix is singular.model.reset()— discards the cached symbolic factorization; call before the nextsolve()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 meansBwas ill-conditioned).dc_approximationis"ignore_r"(default,b = 1/x, what every other tool computes) or"ignore_g"(b = x/(r²+x²), the exact coefficient). They differ only wherer/xvaries 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_busover every branch.Noneif 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.Noneif 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 ismodel.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 callingoutage_flowsrepeatedly: 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 ofactive_injection=bus,reactive_injection=bus,transformer_ratio=branchorphase_shift=branch, plusterminal="from"|"to". Returnsd_branch_active,d_branch_reactive(per branch) andd_voltage_magnitude,d_voltage_angle(per bus).s.row(...)— the adjoint direction, one solve per monitored quantity. Pass eitherbranch=withquantity="active"|"reactive", orbus=withquantity="magnitude"|"angle". Returnsd_active_injection,d_reactive_injection(per bus) andd_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 topathwith its extension replaced by.opf.json, which is the pairgridoxide-matpowerwrites. Raises if no optimal dispatch exists.solveris"ipm"(default), the built-in interior-point method, or"highs", available only when the extension was also built withopf-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_approximationdefaults to"ignore_g"(b = x/(r²+x²)), the opposite ofPowerFlowModel.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; withallow_shedding=Falsesuch a case raises instead, which is sometimes the answer wanted.result.binding— the branches at their limit, each withbranch,flow,rateandprice.
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)—scalingpicks the IEC 60909 voltage factorc:"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, andsequenceas(zero, positive, negative)magnitudes.result.faults— per fault:i_f,i_f_angle, per phase.result.sources— each source's contribution,iandi_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()
topologyis"bus_branch"(default, every switch merged — gridoxide's historical behaviour) or"node_breaker".retainselects which switches survive as elements:"none"(merge them all, but still derive buses fromConnectivityNode),"busbar_adjacent"(the bus-breaker view) or"all"(the full node-breaker view). It is rejected withtopology="bus_branch", where it would mean nothing.model.switches()— one tuple per retained switch, identified by its CGMES mRID.is_openis the current position, so it followsset_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 asolve(), inswitches()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.statusis"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
| Backend | Notes |
|---|---|
"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, needsMKLROOTset).
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 nodesor
gridoxide-generate-grid grid.json --target-nodes 2200 --seed 42. -
gridoxide.matpower(needspip install gridoxide[matpower]) — converts a raw MATPOWER.m/.matcase 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_bushas 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:
| Code | Meaning |
|---|---|
GRIDOXIDE_STATUS_OK | Succeeded. |
..._INVALID_ARGUMENT | Your mistake: null pointer, bad index, wrong buffer length, unavailable backend. |
..._IO / ..._PARSE | The file could not be read / the document could not be parsed. |
..._NOT_CONVERGED | Ran out of iterations. The state is still readable. |
..._SINGULAR | The Jacobian was singular. |
..._NO_ANSWER | The question does not apply here. Not a failure. |
..._PANIC | A 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
capiwithpython.pyo3'sextension-moduleleaves libpython deliberately unlinked — right for something Pythondlopens, wrong for a library a C++ binary links directly. The two cannot come from one cargo invocation. The same trap thepythonfeature 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_networks | the networks below a directory, with their format |
summary | size, bus types, voltage levels, tap changers, regulating controls, areas, and the importer's notes |
power_flow | an AC power flow, with the outer loops gridoxide solve offers as flags (control_taps, enforce_q_limits, distribute_slack, area_interchange, control_remote_voltage) |
results | the 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 Code | the gridoxide extension bundles the binary and offers it to chat; nothing else to install |
| uv | uvx --from gridoxide-mcp gridmcp — no install step |
| pip / pipx | pipx install gridoxide-mcp, then gridmcp |
| cargo | cargo 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 Type | Known | Unknown |
|---|---|---|
| \(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
- Set the iteration counter to \(i=1\). Use the initial solution \(V_{i} = 1 \angle 0^{\circ}\)
- Compute the mismatch vector \(f({x_i})\) using the power flow equations
- 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
- Evaluate the Jacobian matrix \(\textbf{J}_f(x_i)\) and compute \(\Delta x_i\).
- Compute the new solution vector \(x_{i+1}\) and return to step 3.
- 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:
dc_branchesreduces everyLineandTransformerto aDcBranch— a susceptance, a phase shift, and the crate's flat branch index (lines first, then transformers, the same index spacebranch_flow::branch_paramsuses). Branches with no series path — half-open self-loop lines, transformers with an open terminal — produce nothing and are listed inDcSolution::ignored_branches.dc_componentspartitions the buses withtopology::UnionFind. This is deliberately notnetwork::connected_components, which needs a&YBusSparsethat a branch-list solver has no reason to build.network::classifyandnetwork::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.- 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. - Flows, pickup and residual are derived from the solved angles.
DcSolutioncarries per-branch active flows, per-island slack pickup, and amax_residualthat 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
| Tool | Susceptance | Phase shift | Where |
|---|---|---|---|
| gridoxide | both, selectable (DcApproximation) | RHS injection | linear::btheta::dc_power_flow |
| lightsim2grid | \(1/x\), precomputed per branch | RHS injection | BaseDCAlgo.tpp; ydc_* in TwoSidesContainer_rxh_A.hpp |
| powsybl-open-loadflow | both (DcApproximationType) | RHS injection, opposite \(\alpha\) sign convention | dc/equations/DcEquationSystemCreator |
| pandapower | \(1/x\) | Pbusinj (MATPOWER-derived) | rundcpp, makeBdc |
| MATPOWER | \(1/x\) | Pbusinj | dcpf, 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 | |
|---|---|---|
| Arithmetic | real | complex |
| Keeps resistance | no | yes |
| Produces \(|V|\) | no | yes |
| Produces Q | no | yes |
| Exact when | angles are small and \(r \to 0\) | loads are constant-impedance |
| Natural home | transmission | distribution |
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
| Tool | Has it | Where |
|---|---|---|
| gridoxide | yes | linear::impedance::linear_power_flow |
| power-grid-model | yes, plus a linear_current variant | CalculationMethod.linear, linear_pf_solver.hpp |
| VeraGrid | yes, as a distinct linearization | SolverType.LACPF |
| lightsim2grid | no | — |
| powsybl-open-loadflow | no | — |
| pandapower | no | — |
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
PVbus; it is aPQbus 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, ®ulation); // 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:
- 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.
- The loops are nested, innermost first. Each is run to its own stability — an inner check/re-solve loop — before the next is consulted.
- Any loop reporting
Unstablere-solves and becomes the "last unstable" loop. The driver then continues down the list, and wraps back to the start. - 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:
| Loop | What it changes | Invalidates | What the driver does |
|---|---|---|---|
| Distributed slack | p_spec only | Nothing | nothing — the Jacobian is unchanged |
| Reactive limits | bus_type, hence n_unknowns | Pattern | PersistentSolver::reset |
| Tap control | Transformer::tap, hence Y-bus values | Admittances | restamp, 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 removed | equation 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) — including per-machine attribution
- Distributed Slack
- Transformer Tap Control
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:
- Solve to convergence with every
PVbus free (PersistentSolver::solve). If this doesn't converge, stop and return that status — there's no point checking limits on a non-converged solution. - Compute every bus's actual \(Q_k\) (
network::power_injections). - For each
PVbus whose \(Q_k\) violatesq_min/q_max: switchbus_typetoPQand pinq_specto the violated limit. - If no bus was switched this pass, the solution is self-consistent — stop and return
Converged. - Otherwise, reset the cached factorization (
PersistentSolver::reset) and go back to step 1. - If the outer-loop budget runs out without stabilizing, the run reports
budget_exhaustedrather 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
| Tool | Strategy | Where |
|---|---|---|
| gridoxide | 1 — one-directional outer loop | outerloop::ReactiveLimits (opt-in; plain newton_raphson ignores q_min/q_max) |
| MATPOWER | 1 — one-directional outer loop | runpf's enforce_q_lims option |
| pandapower | 1 — one-directional outer loop, pypower/MATPOWER-derived | enforce_q_lims (NR algorithm only, per its own docstring) |
| powsybl-open-loadflow | 2 — bidirectional, capped switch count per bus | ReactiveLimitsOuterLoop (handles capability curves too, not just fixed limits) |
| VeraGrid | 3 — 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:
- Solve the power flow normally.
- Measure the slack's excess over its schedule, \(\Delta = P^{calc}{slack} - P^{sched}{slack}\).
- Add \(\alpha_i \Delta / \sum_j \alpha_j\) to every participating bus's schedule.
- 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:
| Case | Passes to 1e-8 | Moved off the slack |
|---|---|---|
case14_ieee | 7 | 2.3 pu |
case30_ieee | 7 | 2.4 pu |
case118_ieee | 7 | 16.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:
| Knobs | each 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
areaInterchangePMaxMismatchbesideslackBusPMaxMismatch. One tolerance covers both here.
See also
- Outer Loops — the layer this runs on
- Distributed Slack — the degenerate case
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
| Outcome | Meaning |
|---|---|
InDeadband | Inside. The success case. |
Closest | The best position the table offers, target still out of reach. |
AtLimit | It asked for a ratio or angle the changer cannot produce at all. |
Insensitive | It cannot move its own controlled quantity. |
Hunting | It reversed direction too often to be converging. |
Unfinished | The 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
| Importer | Tap table | Regulating control |
|---|---|---|
| CGMES | every position of all four PhaseTapChanger flavours plus RatioTapChangerTable | TapChangerControl, in voltage and activePower modes |
| IIDM | <step> elements | regulating, 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'sCURRENT_LIMITERphase 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
- Outer Loops — the layer this runs on
- AC Sensitivity Analysis — where \(\partial V/\partial\rho\) comes from
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.
| Element | Identity? | Treatment |
|---|---|---|
| CGMES closed switch | No — the bus/branch view is the merged view | Merge (cgmes::merge_closed_switches) |
PGM link | Yes — power-grid-model's output schema carries a link record with its own flows | Branch, at pgm::LINK_Y |
Any branch with \|Z\| below a threshold | Yes — it is a line | Branch, 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
1e8becomes1e16in the gain matrix.
y | power flow | state estimation |
|---|---|---|
1e8 (power-grid-model's) | pass | singular |
1e6 | pass | singular |
2e5 (pgm::LINK_Y) | pass | converge |
1e5 | fail, exactly at tolerance | converge |
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
| Tool | Approach | Where |
|---|---|---|
| gridoxide | 1 and 2, by element identity | cgmes::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-core | 1 — topological reduction, in the bus/branch view | graph traversal of a VoltageLevel's node-breaker topology terminates at open switches and fuses everything reachable through closed ones into one CalculatedBusImpl |
| power-grid-model | 2 — large-admittance regularization | the 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-loadflow | 3 — equality-constrained augmented system | LfZeroImpedanceNetwork 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:
network::connected_components— an iterative DFS overYBusSparse'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).network::classify— counts each component's existingSlackbuses:- exactly one → normal, solvable.
- zero →
network::mark_unreferenced_islandspins every member bus to a fixedV = 0,P = Q = 0placeholder. 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.
- One shared solve (via whichever
JacobianBackendthe caller picked) across the whole (now correctly-classified) bus set. solver::finish_island_reportsrecovers each component's own status after the shared solve, restricting the samepower_injections/effective_injectionmismatch computationnewton_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
| Tool | Island definition (§1) | Which are solved (§2) | Sourceless component (§3) |
|---|---|---|---|
| gridoxide | connected == 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-model | connected == 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-loadflow | both, distinctly: AbstractConnectedComponentsManager traverses AC + DC/HVDC, AbstractSynchronousComponentsManager AC only | opt-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 LoadFlowComponentResult | dropped 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::Blockis refused. Its 2×2-per-bus structure has no home for a scalar \(\lambda\) unknown, sorun_continuationreturnsBackendUnsupportedinstead 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;Scalaris 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:
| Constructor | Scenario |
|---|---|
scale_loads | every net consumer grows at constant power factor; the slack picks it up |
scale_buses | only the named buses grow — a zonal stress |
scale_loads_with_pickup | the pickup is shared over named generators instead of the slack |
to_target / explicit | a 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
| gridoxide | MATPOWER runcpf | PSAT | power-grid-model / pypowsybl / pandapower | |
|---|---|---|---|---|
| Predictor | tangent | tangent or secant | tangent | — none implements continuation |
| Parametrization | natural / local / pseudo-arclength | same three, arclength by default | same three | — |
| Reactive limits | located exactly, by bisection on arclength | event functions, located | yes | — |
| Limit-induced bifurcation | detected and reported as a distinct kind | detected | detected | — |
| Lower branch | traced | traced | traced | — |
| Weakest bus | right tangent at the nose, ranked | available | participation 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_ratedis a voltage base, sobase_mvais 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_reachedmeans 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_controlledmeans the bus was alreadyPV, 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 × stepspower 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:
| Solve | One solve gives you | Ask when | |
|---|---|---|---|
| Forward | \(J z = e_i\), per variable | every function's response to that variable | few things move, many are watched |
| Adjoint | \(J^{\mathsf T} w = \partial f/\partial x\), per function | that function's response to every variable | one 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
| Variable | Meaning | Zero when |
|---|---|---|
ActiveInjection(bus) | \(P\) injected at a bus | the bus is slack |
ReactiveInjection(bus) | \(Q\) injected at a bus | the 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.
- A second Jacobian, checked against the first.
injection_jacobiancomputes the same first derivatives asjacobian::JacobianPatternby 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. Oncase118_ieeethat is over a thousand entries agreeing to 1e-9. - 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_pwlis now generated as an exactly equivalent rewrite ofcase5_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\):
| Formula | Who 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
case30was 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:
| Control | Status | Why |
|---|---|---|
| Generator active power | ✅ | The decision variable. |
| Load shedding | ✅ | Linear, priced at shed_price. |
| Phase-shifter angle | ⚠️ read, held fixed | Linear 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-OPF | AC-OPF | |
|---|---|---|
| Problem class | Convex QP | Nonconvex NLP |
| Guarantee | Provably global optimum | Local 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\) |
| Solver | opf::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:
- Per-bus voltage limits. Defaulting to
[0.9, 1.1]matchedcase3andcase5exactly 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. - 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:
| gridoxide | IPOPT | |
|---|---|---|
| Hessian | \(\nabla^2 f - \sum_i y_i \nabla^2 c_i\) | \(\sigma \nabla^2 f + \sum_i \lambda_i \nabla^2 c_i\) |
| Triangle | Full symmetric | Lower 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
libipoptis 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 beforemain, before any Rust code runs. No amount of care inside the binding can catch it; a plain C program that merely links-lipoptand calls nothing hangs identically, which is how it was pinned on the package rather than the binding..cargo/config.tomlsetsOMPI_MCA_btl=selfto 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:
| Backend | Integrality |
|---|---|
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\) kV | 1.10 | 0.95 |
| \(U_n > 1\) kV | 1.10 | 1.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
| Type | Phases | Rough frequency in practice | Zero sequence? |
|---|---|---|---|
| Three-phase | all | rare | no (balanced) |
| Single-phase-to-ground | one | the large majority | yes |
| Two-phase | two, clear of ground | uncommon | no |
| Two-phase-to-ground | two, plus ground | uncommon | yes |
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:
| Type | Default |
|---|---|
| Three-phase | abc |
| Single-phase-to-ground | a |
| Two-phase | bc |
| Two-phase-to-ground | bc |
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.
| Fault | Phase-domain conditions | Sequence 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 | ≈ 0 | large | ≈ 0 |
| Line-to-ground | present | present | present |
| Two-phase | exactly 0 | present | present |
| Two-phase-to-ground | present | present | 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.
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
- Solve a power flow. Every device's initial state is derived from the operating point, so there has to be one.
- 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.
- 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
| Machines | classical (2nd order), transient (4th), salient-pole (5th), subtransient (6th) |
| Excitation | SEXS, and a purely proportional regulator |
| Governors | TGOV1, and a purely proportional governor |
| Stabilizers | washout plus two lead-lag stages |
| Loads | constant admittance by default; ZIP with a low-voltage cutoff on request |
| Limits | non-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 |
| Events | bus faults, clearings, branch trip and close, unit trip and close, load steps |
| Protection | relays on bus voltage, machine speed or angle excursion, with a delay and a located crossing |
| Input | gridoxide 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:
- The machine reproduces its own terminal current.
injection(x₀, V₀) − y_norton·V₀must equalconj(S/V). A transposed sine and cosine, aqaxis defined the other way round, or a sign slip in the stator solve all survive a plausibility reading; none survives this. - A machine with no subtransient saliency cancels its own Norton stamp, so
∂I/∂Vis 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
| Model | States | What it adds |
|---|---|---|
| Classical | δ, ω | Constant voltage behind transient reactance. The model the equal-area criterion is written for |
| Transient (4th) | + e'_q, e'_d | A field winding — the first model an exciter can act on — and one q-axis damper |
| Salient (5th) | + ψ_1d on d, one flux on q | A 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, ψ_2q | Two 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:
- 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.
- 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
| Kind | Models |
|---|---|
| Excitation | SEXS (lead-lag into a lag), and a purely proportional regulator |
| Governor | TGOV1 (droop into two lags), and a purely proportional governor |
| Stabilizer | washout 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:
- Step exactly onto the event time.
- Apply the change and reassemble the Y-bus.
- Hold
xfixed and re-solveY V − I_inj(x, V) = 0forValone. - 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
| Kind | Effect |
|---|---|
BusFault | Add a shunt admittance to ground. Replaces any fault already standing at that bus rather than adding to it |
ClearFault | Remove it |
BranchTrip / BranchClose | Take a branch out of service, or put it back |
UnitTrip / UnitClose | Disconnect a generating unit, or reconnect it |
LoadStep | Change 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-3relative; - 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-2to7e-3. Raising the dimension to 128 converged to2e-19in 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 reported8e-52where the truth was1.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 the1e-4level with residuals at3e-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:
thetaatt = 0is 1.224004 rad. gridoxide's own initialization, from an entirely independent derivation, putsδ₀within6e-5of it. That is the rotor-frame convention agreeing with a second implementation — much stronger evidence than any self-consistency check.PmPuis 0.903 whilePGenPuis 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 fault | 9.1e-4 rad |
| Rotor speed through the fault | 6.6e-5 pu |
| Terminal voltage during the fault | 9.0e-4 pu |
| Rotor angle over the first swing | 1.9e-2 rad |
| The published 70 ms clearing time | survivable 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-4rad betweenh = 4 msandh = 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 under1e-3rad, 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:
| approximate | full form | |
|---|---|---|
| Post-fault power offset at matched angle | −0.611% | −0.026% |
| Rotor angle over the first swing | 1.89e-2 rad | 1.76e-3 rad |
| Rotor angle over the whole 5 s | 2.06e-1 rad | 3.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 | |
|---|---|---|
| Solves | the nonlinear problem | a linearized one, re-linearized each pass |
| Per iteration | fresh Jacobian and factorization | right-hand side only |
| Convergence | quadratic | linear |
| Accuracy | the true WLS optimum | approximate — 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:
| Sensor | Reference direction | Positive means |
|---|---|---|
| Branch terminal | branch | power flows from the node into the branch |
| Load, shunt | load | power flows from the node into the appliance (consumption) |
| Source, generator | generator | power flows into the node |
| Node injection | generator | net 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 sensor | gridoxide's model | \(h(x)\) |
|---|---|---|
source | virtual slack bus behind an impedance branch | that branch's flow, negated |
shunt | Y-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 leavesp_specifiedunset, 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.
| case | buses | measurements | Newton-Raphson | iters | iterative-linear | iters | speedup |
|---|---|---|---|---|---|---|---|
| case14 | 15 | 125 | 0.7 ms | 6 | 0.1 ms | 16 | 7x |
| case118 | 119 | 1,083 | 5.5 ms | 6 | 1.0 ms | 37 | 5.5x |
| case300 | 301 | 2,419 | 13.8 ms | 6 | 2.3 ms | 41 | 6x |
| case1354pegase | 1,355 | 11,189 | 137 ms | 6 | 12.8 ms | 45 | 10.7x |
| case2869pegase | 2,870 | 25,204 | 349 ms | 6 | 30.6 ms | 33 | 11.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-Raphson | iterative-linear | |
|---|---|---|
assembly (h(x), H, gain) | 22% | — |
| factorization | 78% | ~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:
| case | buses | PGM its | gridoxide its | iterations | ms per iteration |
|---|---|---|---|---|---|
| case14 | 14 | 15 | 31 | 2.07x | 0.20x |
| case118 | 118 | 18 | 35 | 1.94x | 0.57x |
| case300 | 300 | 9 | 29 | 3.22x | 0.62x |
| case1354pegase | 1,354 | 10 | 28 | 2.80x | 0.71x |
| case2869pegase | 2,869 | 10 | 33 | 3.30x | 0.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:
| case | before | after | PGM |
|---|---|---|---|
| case14 | 31 | 19 | 15 |
| case118 | 35 | 21 | 18 |
| case300 | 29 | 18 | 9 |
| case1354pegase | 28 | 17 | 10 |
| case2869pegase | 33 | 20 | 10 |
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 — itsiterative_linear_se_solver.hppsays 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.
| Fixture | Iterations | Max \(\Delta \vert V \vert\) vs power-grid-model |
|---|---|---|
1os2msr | 11 | 3.4e-10 |
1os2msr-no-angle | 11 | 5.7e-10 |
inf-measurement-with-injection | 2 | 4.0e-9 |
transmission-case | 20 | 4.3e-9 |
node-injection-sensor-and-zero-injection | 1 | 4.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\) | dof | Rejected at 5% |
|---|---|---|---|
1os2msr | 3.4e-19 | 14 | no |
1os2msr-no-angle | 9.6e-22 | 12 | no |
inf-measurement-with-injection | 1.1e-20 | 2 | no |
transmission-case | 2.4e-7 | 48 | no |
node-injection-sensor-and-zero-injection | 2.0e4 | 4 | yes |
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\) |
|---|---|---|---|---|
| 1 | flow 1→2 | \(10(\theta_1 - \theta_2) = -10,\theta_2\) | 0.20 | 0.01 |
| 2 | flow 1→3 | \(-10,\theta_3\) | 0.42 | 0.01 |
| 3 | flow 3→2 | \(10(\theta_3 - \theta_2)\) | −0.18 | 0.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 flow | that branch's own half-row | the terminal's bus |
| Shunt draw | a single negated admittance | the 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\) | dof | Rejected at 5% |
|---|---|---|---|
1os2msr | 3.4e-19 | 14 | no |
transmission-case | 2.4e-7 | 48 | no |
node-injection-sensor-and-zero-injection | 2.0e4 | 4 | yes |
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
| Term | What it is |
|---|---|
| CRAC | Contingencies, Remedial Actions and Additional Constraints — the document defining the problem. Everything below comes out of it. |
| Contingency | The simultaneous loss of one or more elements. More than one element makes it N-k. |
| Instant | A moment in the chronology after a contingency: preventive, outage, auto, curative. |
| State | A (contingency, instant) pair. The preventive state has no contingency. |
| CNEC | Critical Network Element and Contingency — one monitored element in one state. The same line yields a different CNEC per state, with different limits. |
| Threshold | A limit on a CNEC's flow, in MW, amperes, or a fraction of \(i_{max}\). |
| Range action | A control with a continuum of settings: a phase shifter's angle, a redispatch in MW. |
| Network action | A discrete control: open a switch, change a topology. |
| Perimeter | A 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
| Layer | Answers | Code | Page |
|---|---|---|---|
| Evaluation | where does it hurt, and by how much | rao::evaluate | Margins |
| Linear optimization | what should the continuous controls be | rao::linear | The LP |
| Search and decomposition | which discrete actions, in which perimeter | rao::search, rao::castor | The 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.txt | 180 of 186 | |
ac_scenarios.txt | 276 of 282 | TestCase12Nodes |
ac_scenarios_16nodes.txt | 963 of 977 | TestCase16Nodes |
second_preventive.txt | 123 of 123 | |
min_cost.txt | 288 of 294 | costly 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_INTEGERSis read by nothing andTapModel::Discreteis 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
relativeToPreviousInstantcurative 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::Heldcarries 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
relativeToPreviousInstantrange 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
| Unit | Meaning | Conversion to MW |
|---|---|---|
megawatt | active power on the branch | none |
ampere | current | \(P = \sqrt{3}, U, I\) |
percentImax | a fraction of the CNEC's own \(i_{max}\) | \(I = \phi \cdot i_{max}\), then as above |
degree, kilovolt | angle and voltage CNECs | not 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
_generatoror_loadremoved, because powsybl names a UCTE node's generator<node>_generatorand 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:
| Column | Variable | Bounds | Objective coefficient |
|---|---|---|---|
| \(3k\) | \(A(r_k)\), the set-point | the action's own range | 0 |
| \(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 started | floor | reading |
|---|---|---|
| \(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
| Tap | Angle |
|---|---|
| 12 | 4.6727° |
| 13 | 5.0617° |
| 14 | 5.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:
| kind | anchored on | means |
|---|---|---|
absolute | nothing | the bounds are tap positions |
relativeToInitialNetwork | the tap in the network as imported | "no more than n taps from where the file had it" |
relativeToPreviousInstant | the 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 (vianetwork::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_raphsoncomputes 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, andKluNativeare all meaningfully faster thanScalar.KluandKluNativeland close to each other, slightly ahead ofBlock.Pardisocarries a largely size-independent fixed setup cost from its default matching/scaling preprocessing, making it the slowest backend at small problem sizes — even behindScalar— though it scales better thanScalaras 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:
| Phase | What it does | Depends on | Code |
|---|---|---|---|
| 0. CSC assembly | triplets → compressed sparse column | pattern | klu_native/mod.rs |
| 1. BTF | permute to block upper-triangular form | pattern | klu_native/btf/ |
| 2. AMD | reorder inside each block to reduce fill | pattern | klu_native/amd/ |
| 3. Factor | Gilbert-Peierls LU with partial pivoting | values | klu_native/kernel.rs, factor.rs |
| 4. Solve | permuted forward/back substitution | values | klu_native/solve.rs |
| 5. Refactor | redo phase 3 with new values, same pattern | values | klu_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.rsappliespblktopandqidentically. - Blocks of size ≤ 3 skip AMD and keep their natural order (
analyze_worker's own threshold, matched exactly inanalyze.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 loosetolis 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
Jwith 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.rsimplements both of KLU's variants and is differentially tested against the C, butfactor/refactorcurrently 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
f64only, single right-hand side,int32-range indices. No complex arithmetic, no batched multi-RHS, noDLONG. 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
Optionshas noorderingfield to select them. - No
maxworklimit 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
| File | Ports | Contents |
|---|---|---|
crates/gridoxide-sparse/src/klu_native/types.rs | klu_internal.h, klu_defaults.c | EMPTY/flip sentinels, Options |
crates/gridoxide-sparse/src/klu_native/btf/maxtrans.rs | btf_maxtrans.c | step 1a, bipartite matching |
crates/gridoxide-sparse/src/klu_native/btf/strongcomp.rs | btf_strongcomp.c | step 1b, Tarjan SCC |
crates/gridoxide-sparse/src/klu_native/btf/mod.rs | btf_order.c | step 1, both halves combined |
crates/gridoxide-sparse/src/klu_native/amd/ | amd_order.c, amd_2.c | step 2, approximate minimum degree |
crates/gridoxide-sparse/src/klu_native/analyze.rs | klu_analyze.c | steps 1–2 driver, produces Symbolic |
crates/gridoxide-sparse/src/klu_native/kernel.rs | klu_kernel.c | step 3, one block: DFS, pivot, prune |
crates/gridoxide-sparse/src/klu_native/factor.rs | klu_factor.c | step 3 driver, off-diagonal bookkeeping |
crates/gridoxide-sparse/src/klu_native/scale.rs | klu_scale.c | row scaling (not wired in) |
crates/gridoxide-sparse/src/klu_native/refactor.rs | klu_refactor.c | step 5 |
crates/gridoxide-sparse/src/klu_native/solve.rs | klu_solve.c | step 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_serviceso a later phase can reconnect them. ##TT,##Eand 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,vscConverterStationandlccConverterStationare 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
vscConverterStationshared thegeneratorarm, and since a converter station states notargetPit 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 innotesand skipped. NotablyslackTerminalis 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 innotes. -
A generator's reactive capability as a curve.
reactiveCapabilityCurvestatesminQ/maxQas a function of P;types::Buscarries 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. AminMaxReactiveLimitspair is read exactly. -
Asymmetric line shunts. IIDM states
g1/b1andg2/b2separately;Linecarries one total that the π-model splits equally. Every line in every vendored fixture is symmetric, and any that is not is counted innotesrather 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 says | What is solved |
|---|---|
| Bus type 3 or 2 with an online generator | Reference or PV bus |
| Bus type 3 or 2 without one | PQ bus; if no reference remains, the first PV bus becomes it |
| A generator on a PQ bus | A fixed Pg + jQg injection |
| The voltage a regulated bus holds | Its generators' Vg (the last online one), never the bus's Vm, which MATPOWER reads only as a starting value |
| The reference angle | The 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 shift | A two-winding transformer: the π-model with the complex ratio τ·e^{jθ} on the from side, exactly network::branch_calc_param's model |
Gs, Bs | A 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.
TopologicalNodeis used directly as gridoxide'sBus, so switch-state topology processing is assumed already resolved upstream. See Ideal Switches and Zero-Impedance Branches for what that resolution involves and howcgmes::merge_closed_switcheshandles 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):
| Case | Test | Notes |
|---|---|---|
| MicroGrid-BE-MAS | tests/cgmes_microgrid_be_test.rs | |
| MiniGrid | tests/cgmes_minigrid_test.rs | First 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 PST | tests/cgmes_pst_phase_tap_changer_linear_test.rs | Matches published SV values to ~1e-3 |
| RealGrid | tests/cgmes_realgrid_test.rs | Large 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:
| fixture | lines spanning two bases | worst error before | after |
|---|---|---|---|
| MicroGrid-Type1 | 5 of 13 | 4.4% | 1.3% |
| FullGrid | 5 of 13 | — (does not converge) | — |
| SmallGrid, MiniGrid, RealGrid, Svedala | 0 | unchanged, 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?
| Policy | Keeps | Typical use |
|---|---|---|
MergeAll | nothing | Reproduces the classical bus-branch view exactly. The baseline. |
RetainAdjacentToBusbar | switches with a BusbarSection on at least one side | Busbar 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 name | Studying a specific bay. |
RetainAll | everything | The full node-breaker network, one bus per connectivity node. |
The cost is bus count, and it is not small. On the conformance configurations:
| Model | Connectivity nodes | MergeAll | RetainAdjacentToBusbar | RetainAll |
|---|---|---|---|---|
| MiniGrid | 103 | 15 buses, 0 switches | 45 buses, 30 switches | 105 buses, 90 switches |
| SmallGrid | 1,369 | 167 buses, 0 switches | 540 buses, 373 switches | 1,369 buses, 1,266 switches |
| Svedala | 1,179 | 228 buses, 0 switches | 638 buses, 857 switches | 1,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_flowis just a branch flow. It has a PTDF/LODF column, so a switch'slodf_columnis its bus-split distribution factor. A switching campaign is an ordinaryBatchSolver::solve_contingenciessweep. 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
PersistentSolverkeep its symbolic factorization across states. Merging cannot do this — closing a switch would changen.
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, notflat_start. CGMES marks a de-energized bus by leaving it out of everyTopologicalIsland, 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 wherelinear_startconverges in three iterations to 4e-10. - De-energized buses are not an observability gap.
observability::analyzereports their unknowns separately, inde_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
RegulatingControlreference — the same voltage-mode-target mechanismSynchronousMachineuses.
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:
| Fixture | inductiveRating | capacitiveRating |
|---|---|---|
| MicroGrid (BaseCase, Type1, Type2) | −5062.5 | 5062.5 |
| Svedala (×2) | −800 | 800 |
| RealGrid | −506.25, −675, −337.5 | 202.5, 675, 337.5 |
| FullGrid | −0.99 | 0.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 fromPQtoPVand pinned to the target voltage: concept 2's hard pin. - Otherwise it falls back to concept 4's fixed Q injection from the SSH
qvalue, using the same sign conventionSynchronousMachine.qalready 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.slopeis not read at all — every regulating SVC is a hard voltage pin, the same simplification gridoxide already makes forSynchronousMachine. - No standby/monitoring mode. A non-regulating SVC falls back to its fixed
qinjection permanently; there's no outer loop that would later switch it back into regulation if voltage left some dead-band, since gridoxide's plainnewton_raphsondoesn't run any outer loop for SVCs at all (only for PV→PQ switching, and only when theouterloop::ReactiveLimitsloop is configured, which the default solve does not do).
Tool reference
| Tool | Rating storage (§1) | Regulation (§2) | Remote (§3) |
|---|---|---|---|
| gridoxide | ohm rating → per-unit Q limit at \(V=1\), anchored to the SVC's own bus (src/cgmes.rs) | hard pin only | ✅ |
| powsybl-core | bMin/bMax in siemens, via getB() = 1 / rating in StaticVarCompensatorConversion.java; zero/absent rating → ±Double.MAX_VALUE | data 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-loadflow | consumes 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 |
| VeraGrid | stepped Bmin/Bmax on its ControllableShunt | regulates a control_bus's voltage to Vset | ✅ |
| pandapower | create_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
| Tool | Parameterization | Where |
|---|---|---|
| gridoxide | 1 — whole-line g_shunt, split evenly in the Y-bus stamp | types::Line::g_shunt, network::build_ybus; read from gch by src/cgmes.rs, always 0 from the PGM importer |
| powsybl-core | 1 — 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-loadflow | 1 — same per-end getG1()/getG2(), genuinely in the solved equations, not inert metadata | AbstractBranchAcFlowEquationTerm (P/Q mismatch terms), AcBranchVector (vectorized evaluator), LfAsymLineAdmittanceMatrix |
| lightsim2grid | 2 — 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++ core | element_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-model | 3 — c1 + tan1, with \(g = \omega c_1 \tan\delta_1\) feeding the same y1_shunt_/y0_shunt_ terms | component/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
| Tool | Per-step model (§1) | x interpolation (§2) | Tap movement (§3) |
|---|---|---|---|
| gridoxide | all 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-core | all 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-loadflow | consumes 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 |
| lightsim2grid | a 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-model | n/a — no CGMES import | n/a | ✅ TapChangingStrategy outer loop |
| VeraGrid | not surveyed (has a CIM importer) | not surveyed | ✅ control_taps_phase |
| pandapower | not 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
| Tool | Precedence (§1) | Fields read (§2) | Table scope (§3) |
|---|---|---|---|
| gridoxide | table first, linear formula on absent/missing point (ratio_tap_table returns None) | ratio, x | current SSH step only |
| powsybl-core | table first, formula on absent/empty/invalid table — CgmesRatioTapChangerBuilder.addSteps() dispatches to addStepsFromTable or addStepsFromLowHighIncrement, with an explicit isTableValid() check between | ratio, r, x, g, b per row | full 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
| Tool | Internal type (§1) | Sign (§2) | Boundary folding (§3) |
|---|---|---|---|
| gridoxide | bus-level injection; PV upgrade if voltage-regulating, else fixed P/Q (src/cgmes.rs) | both negated | ❌ no boundary-line model |
| powsybl-core | IIDM Generator (EnergySource.OTHER) for both CGMES classes, distinguished only by a PROPERTY_CGMES_ORIGINAL_CLASS string for round-trip export | both 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-loadflow | none 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 it | inherited | inherited |
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
| Feature | lightsim2grid | power-grid-model | powsybl-open-loadflow | VeraGrid | pandapower | gridoxide (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 components | not surveyed | not 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 surveyed | not 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 against | not 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 | ❌ same | not surveyed | not 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 | ❌ same | not surveyed | not 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 | ❌ same | not surveyed | not 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 | ❌ same | not surveyed | not 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 solver | KLU/Eigen/NICSLU/CKTSO, pluggable at runtime | hand-rolled 2×2-block LU, pivot perturbation off by default | KLU 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 pluggable | SciPy's spsolve (pypower/newtonpf.py), with an optional use_umfpack flag — UMFPACK is a SuiteSparse sibling of KLU, when scikit-umfpack is installed | faer (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.rstis refreshingly explicit about what it doesn't do: no Q-limit enforcement, fixed taps mid-solve, steady-state only, symmetric only. ContingencyAnalysisandTimeSeriebatch classes reuse Ybus factorization across many solves rather than rebuilding from scratch — same idea as gridoxide'sPersistentSolver, 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_regulatorcomponent, which fixes|U|and solves for Q;q_min/q_maxexist on it but the automatic PV→PQ switching is explicitly flagged as not fully implemented. - Same hand-rolled block-sparse LU architecture gridoxide's
Blockbackend 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 thenewton_raphson_pf_solver.hppcall site — this is what caused theSparseMatrixErrors 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. TapChangingStrategyouter loop (disabled by default):any_valid_tap,min_voltage_tap,max_voltage_tap,fast_any_tap.validate_input_data/validate_batch_dataexist 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/OuterLoopResultabstractions, 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'sLUDecomposition/MatrixFactoryabstraction; native KLU via JNI is the primary path (same library gridoxide's ownKlubackend vendors directly).
VeraGrid (Python, SanPen/VeraGrid, the GridCal successor)
- By far the broadest simulation-category scope of the five — installed as the headless
VeraGridEnginepackage (not the Qt-GUI-bundledVeraGridpackage), itsSimulations/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 onePowerFlowOptions.solver_typeenum — though pandapower's ownalgorithmparameter 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'sOuterLoopabstraction 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
ControllableShuntdevice (steppedBmin/Bmaxregulating a bus's voltage to a setpoint) filling the SVC role neither lightsim2grid, PGM, nor powsybl have — broad FACTS coverage, though pandapower's own dedicatedcreate_svc/create_tcsc/create_sscset turns out broader still (see below). - MATPOWER import (
parse_matpower_file) reads each bus'stypecolumn and each generator'sVgsetpoint directly, so genuine PV-bus modeling comes for free with no PGM-voltage_regulator-style conversion step — seescripts/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/BasicCtrlframework (control/basic_controller.py, driven byrun_control=True) is genuinely extensible — any third-party code can subclassControllerand register it onnet.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. contingencymodule includes arun_contingency_ls2gvariant 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'svalidate_input_datathan to VeraGrid's format-specific-only import validation.- Also has a dedicated
protectionpackage (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 owninit_from_pandapower) — see Backends and Factorization Reuse andscripts/bench/README.mdfor 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'sContingencyAnalysis/TimeSerieand 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'sTimeSerieon the injection-scenario case; still short of both on contingency, which needs per-scenario topology (see gap 2 below). (bde::solve_batch_block_diagonalstacks a batch into one block-diagonal factorization instead, validated bit-exact against independent per-scenario solves inscripts/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 6SparseMatrixError/ 4IterationDivergecases 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 solvesMicroGrid-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-gridssubmodule 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.pychecks a solved case against the MATPOWER file's own power-flow equations, andcheck_cgmes_sv_consistency.pychecks a CGMES fixture's publishedSvVoltageagainst 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'svalidate_input_data, pandapower'sdiagnostic()).
Identified gaps, ranked by how often reference tools flag them as important
-
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
Busalready carriedq_min/q_max, unused. Done —outerloop::ReactiveLimitsimplements the standard MATPOWER-style one-directional PV→PQ switching outer loop, tested intests/q_limits_test.rsacross all three Jacobian backends;PgmVoltageRegulatornow parses PGM's ownq_min/q_maxfields. Opt-in: plainnewton_raphson/PersistentSolver::solveare unchanged, so no existing test/benchmark behavior shifted. It wassolver::newton_raphson_enforcing_q_limits, a standalone entry point, until the outer-loop layer made it composable with the two below. -
Contingency/N-1 batch analysis — the one gap with most of its machinery already standing:
PersistentSolver's factorization reuse andbatch::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_flowsgives post-outage flows from one triangular solve with no refactorization (and reports radial branches, which have no redistribution factors), whilelinear::batch::DcBatchSolverruns injection scenarios against a single factorization for the whole batch — 9x to 55x per scenario against independent solves, seescripts/bench/README.md§8. N-2 and N-k are covered too, via the generalized (Woodbury) formulation inmulti_outage_flows— 0.40 ms per pair on case9241pegase against a 13.4 ms re-solve — includingis_breaking_setfor outage sets that disconnect the network, which single-branch screening misses by construction. AC contingency is done as well, viabatch::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, becausenetwork::build_ybus_with_outagestakes 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_componentscannot 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 reportNoReferenceBushonestly instead of a singular solve. -
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 ownp_specschedules 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: onlyp_specchanges between passes, so bus types,n_unknownsand the Jacobian's sparsity pattern all hold andPersistentSolverkeeps 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_ieeeandcase118_ieeeeach take seven passes, moving 2.3, 2.4 and 16.5 per-unit off their slacks. Tested intests/distributed_slack_test.rs, with every check re-deriving injections fromnetwork::power_injectionsrather than reading the loop's own bookkeeping.newton_raphson_distributing_slackis nowouterloop::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 fromgridoxide solve --distribute-slack --enforce-q-limitsand fromPowerFlowModel.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'sAcAreaInterchangeControlOuterLoopconstructs aDistributedSlackOuterLoopas 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.netInterchangefor the schedule,TieFlowfor 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##Zcountry codes and no schedule, which is all any UCTE file states; the twelve-node case is a real four-country interconnection. Both reachgridoxide solve --area-interchange.Two things the fixtures corrected. A
TieFlownames 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,AreaDefinitionwants 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::uniformparticipatesSlackandPVbuses 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 owntwo_area_case.xiidmhas both of AREA2's machines atvoltageRegulatorOn="false", so underuniformthat area had no participant and its −400 MW schedule was unreachable.AreaDefinition::by_generationweights byp_specinstead 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.
-
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_guesswas "most of the way there" was only true for one of them: that function is power-grid-model'sCalculationMethod.linear(complex, constant-admittance, keeps resistance and produces|V|), and it is now exposed aslinear::impedance::linear_power_flow. What was genuinely missing was real Bθ — the thing lightsim2grid, powsybl-open-loadflow and pandapower'srundcppmean by DC — and that is new code inlinear::btheta, with both of powsybl's approximation variants, phase-shifter support, per-island factorization, and PTDF/LODF sensitivities on top. Seedocs/src/powerflow/dc.mdandlinear_impedance.md; tested intests/dc_powerflow_test.rsandtests/dc_sensitivity_test.rs. -
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.
-
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 —Line3Phcarriesr0/x0,transformer_seq_paramsreturns(y0, y1, y2), andnetwork::fortescue_to_phasewas 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_paramssupported 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);Line3Phhad no conductance field, silently dropping thetan δdielectric-loss term; and the three-phase path droppedlinkcomponents 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.mdandKnownDivergencein the test record which, why, and by how much. -
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_transposereusing 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_contingenciesre-solves instead) and differentiation through the outer loops. -
DC-OPF (
opf::dc,opffeature) — 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 ofinjection_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.
-
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-gridssubmodule 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
BestTapFinderrounding 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_INTEGERSalready match with continuous optimization and rounding, including six the reference built as controlled pairs against their own continuous twins. -
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 movep_specas a function of the solved state, which corrupts the tangent — and are folded into the loading direction analytically instead.dynawo-algorithmsoffers voltage margin and load-increase-to-collapse as an external cross-check. - Quasi-static time series is next, and half-built:
BatchSolveralready 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ω ≈ 1stator 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.
- Voltage stability / continuation power flow was the cheapest by a distance, and is now
built (
-
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_limitsandnewton_raphson_distributing_slackwere 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 areouterloop::OuterLoopimplementations now, driven over an ordered list by powsybl's own schedule, withTransformerVoltageControlandPhaseControlas 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_Type2asks 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_ptis aHashMapand 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::analyzedoes both. - Newton-Raphson robustness. power-grid-model's Newton-Raphson estimator raises
SparseMatrixErroron 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. Seescripts/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:
-
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_3phbuilds the measurement model for a 3N-bus network,pgm::pgm_3ph_mapssupplies the object-ID maps a sensor needs to resolve against it, andpgm::measurement::measurements_from_pgm_3phmaps sensors onto phase-expanded targets.tests/se_three_phase_test.rsestimates power-grid-model'stransmission-casein 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-coefficientCurrentFunctionalwhere a scalar one has two. A branch is indexed3·branch + phaseto match the3·node + phasebus convention, which is what keepsTargetidentical 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
StateLayoutremoves one.tests/se_three_phase_test.rsnow 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_3phis 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 --asymmetricis that door, as isStateEstimationModel.from_pgm_json(..., asymmetric=True)— the same class, withsolve,solve_batch,observabilityandbad_dataunchanged, 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-casethe phase domain converges at a 15σ error and diverges at 20σ, the symmetric problem converges at 20σ and diverges at 50σ. It reportsMaxIterationsrather than a converged-looking answer, which is the part that matters — a diverged state carries residuals of1e17, 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_ratedin the scalar case and line-to-neutral overu_rated/√3here, which is the same number for a balanced set, and a power reading is a three-phase total overs_baseagainst a per-phase value overs_base/3, likewise. So the value replicates and only the angle rotates by 0/−120/+120 — which is exactly power-grid-model's ownComplexValue<asymmetric_t>broadcast.Asymmetric sensors describe their three phases separately here rather than reducing to the symmetric problem:
asym_voltage_sensor,asym_power_sensorandasym_current_sensoreach select their own phase's reading, against a line-to-neutral voltage base and as_base/3power base. Checked againstsingle-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
StateLayoutremoves 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 leavesSeReport::unconstrainednaming 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-sensorreads 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, soV_virtual = V_nodeexactly. 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_mapsno longer returns aResult— 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_paramsgrew power-grid-model's general zero-sequence algorithm.voltage_regulatoris 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. Andlinkis now modelled: an ideal connection is phase-transparent, so its sequence parameters are onebranch_calc_paramrepeated — which is literally what power-grid-model's ownlink.hppcomputes,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_Yand 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_transformeris 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_legsnow 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 iswye_n/wye_nat clock 0 whateverwinding_1is, while T2 and T3 carry their own winding againstwinding_1at a reversed clock, exactly asthree_winding_transformer.hpp::convert_to_two_winding_transformersspecifies.PgmThreeWindingTransformergrewwinding_1/_2/_3fields 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.
-
Current sensors.Done.sym_current_sensorandasym_current_sensorare read in both angle frames, on both calculation methods, checked against power-grid-model's ownglobal-current-sensorandlocal-current-sensorfixtures — 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 nophase_mod_2pianywhere, 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
linkis 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 throughObservabilityReport::global_current_without_angle_referenceinstead. The state is fully determined there — determined to the wrong reference, sinceStateLayoutpins a bus the sensor contradicts — so calling it unobservable would misname it. -
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 shapebatch::BatchSolverhas for power flow, and exactly whatPersistentEstimator's already-written cache-validity condition allows.MeasurementOverridevaries values and sigmas and refuses to varykindortarget, mirroringBusOverride's refusal to changebus_type. Exposed asStateEstimationModel.solve_batch(scenarios, threads). Checked against power-grid-model's ownsensor-update-*andunbalanced-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)
| Source | What it offers | Notes |
|---|---|---|
| Sienna-Platform/PowerSystemsTestData | 5-Bus PJM, 5-Bus-hydro, 118-Bus PCM (high renewables), TAMU ACTIVSg synthetic grids (10k-bus WECC, 70k-bus EI, 2000-bus ERCOT), Matpower cases | Plain case files, not tied to Julia structs — good for parser testing |
| Sienna-Platform/PowerSystemCaseBuilder.jl | 200+ cases pre-built into PowerSystems.jl data model | Julia-native; skips parsing step if you're in that ecosystem |
SanPen/VeraGrid — Grids_and_profiles/grids | IEEE 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/StateEstimation | Reference SE test cases with expected outputs | Directly relevant to DSSE validation |
| power-grid-lib/pglib-opf | Curated 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-hvdc | OPF benchmark cases with HVDC lines (MatACDC format) | Reference impl. in PowerModelsACDC.jl |
Distribution-level / DSSE-relevant test cases
| Source | What it offers | Notes |
|---|---|---|
| e2nIEE/simbench | German research-project MV/LV benchmark networks with full time series | Most relevant to your MV DSSE work; some grids underdetermined for SE out-of-box (need synthesized pseudo-measurements) |
| e2nIEE/pandapower | Matpower/PYPOWER-compatible cases, plus own SE module | Useful for cross-validating your SE implementation |
| IEEE/EPRI distribution feeders (4/13/34/37/123-bus, 8500-node) | Classic unbalanced 3-phase distribution benchmarks | Distributed via OpenDSS/EPRI, mirrored in various repos (e.g. dss-extensions org) rather than one clean source |
CGMES / CIM data
| Source | What it offers | Notes |
|---|---|---|
powsybl-core — cgmes-conformity module | ENTSO-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-assessments | Tracks PowSyBl's own conformity assessment results | Reference for how a mature parser scores |
Realistic large-scale regional network models
| Source | What it offers | Notes |
|---|---|---|
| PyPSA/pypsa-eur | Full ENTSO-E-area European transmission network, generator fleet, sector links | Built via Snakemake from open data (Eurostat, JRC-IDEES); realistic scale, not a "test case" |
| PyPSA/pypsa-usa | US regional network model | Same pattern as pypsa-eur |
| PyPSA/pypsa-za | South Africa network model | Requires separate .7z data bundle download |
| PyPSA/technology-data | Cost/efficiency assumptions by technology and year | Input data for OPF-type work, not topology |
PyPSA/PyPSA — examples/ | Small toy AC/DC networks | Good for quick unit tests |
Dynamic simulation (RMS / DAE)
| Source | What it offers | Notes |
|---|---|---|
| dynawo/dynawo | Validated example cases for DynaFlow, DynaWaltz, DynaSwing | RTE-maintained, MPL-2.0; roadmap includes Nordic32 and IEEE dynamic cases |
| dynawo/dynawo-algorithms | Contingency analysis, voltage margin, load-increase-to-collapse | Tool, not raw data |
| dynawo/dynawo-large-scale-validation | Validation framework vs. legacy simulators (Astre, Hades), extensive contingency runs + Jupyter comparison pipeline | Methodology reference for validating your own RMS/DAE solver |
| dynawo/dyn-grid-compliance-verification | Grid-code compliance verification for generators/IBR (fault-ride-through etc.) | Relevant to IBR/GFM converter work |
| dynawo/IEC-WECC-GenericModels-Comparison | WT Type 4 generic model comparison cases | Wind turbine dynamics specifically |
Tools
Open-source power systems frameworks (readers/solvers)
| Tool | Language | Relevance |
|---|---|---|
| PowSyBl (powsybl-core) | Java | Strongest open-source CGMES compatibility (per FullGrid evaluation); IIDM network format; CGMES conformity data bundled in |
| pandapower | Python | Matpower/PYPOWER-compatible, includes SE module |
| PyPSA | Python | Sector-coupled energy system modeling; large realistic regional models via pypsa-eur/usa/za |
| VeraGrid | Python | Reads Matpower, PSS/E RAW; has SE test infrastructure; fast large-case AC PF |
| Dynawo | Modelica/C++ | RMS dynamic simulation (DAE, IDA/SUNDIALS-based); reads IIDM |
| PowerSystems.jl / PowerSimulations.jl (Sienna) | Julia | NREL-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
| Repo | Language | Relevance |
|---|---|---|
| MadNLP.jl | Julia | Filter 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.jl | Julia | Interior-point solver for linear programs, GPU-capable |
| MadNCL.jl | Julia | MadNLP extension implementing Algorithm NCL; targets infeasible or degenerate NLPs — the failure mode AC-OPF hits on stressed cases |
| CCOpt.jl | Julia | Solver for Mathematical Programs with Complementarity Constraints (MPCCs) — the natural formulation for complementarity-style limit switching (e.g. PV→PQ, tap limits) |
| HybridKKT.jl | Julia | Golub & Greif condensed-KKT solver inside MadNLP; the "how do I factorize an indefinite KKT system on a GPU" answer |
| BlockDSS.jl | Julia | Block-structured (esp. block-tridiagonal) linear solvers, CPU / CUDA / ROCm, Float32 and Float64, sequential and batched variants |
| MadDiff.jl | Julia | Forward/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.jl | Julia | Shared core for the Mad* solvers; no README yet |
| MadNLPGraph.jl | Julia | Graph/structured-decomposition extension for MadNLP; dormant since 2021, MPL-2.0 |
Modeling and AD
| Repo | Language | Relevance |
|---|---|---|
| ExaModels.jl | Julia | SIMD-abstracted algebraic modeling + automatic differentiation targeting GPUs; the modeling front-end MadNLP is usually paired with |
| ExaModelsPower.jl | Julia | AC-OPF (and multi-period / storage) models built on ExaModels — the most directly comparable artifact to gridoxide's OPF formulations |
| ExaPowerIO.jl | Julia | Minimal Matpower-format reader, deliberately narrower than PowerModels.jl. Useful as a second opinion on Matpower parsing edge cases |
| ExaModelsAMPL.jl | Julia | Writes ExaModels models out to AMPL .nl — a bridge for feeding models to any .nl-reading solver |
| ExaModelsExamples.jl | Julia | Worked ExaModels examples |
| DynamicNLPModels.jl | Julia | NLPModels for dynamic optimization / optimal control (MPC-shaped problems) |
| MPCCModels.jl | Julia | NLPModels.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.
| Repo | Language | Relevance |
|---|---|---|
| cnlp-abi | C | The 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.jl | Julia | Loads a cnlp shared library as an NLPModels.AbstractNLPModel, so any Julia solver can solve it |
| cnlpmodels-py | Python | Same, for Python — ctypes + numpy, no extra runtime; works with cyipopt and scipy.optimize |
| examodels-py | Python | Python interface to ExaModels.jl (SIMD-parallel modeling + AD, CPU and GPU) |
| libMad | Julia/CMake | Metapackage 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
| Repo | Language | Relevance |
|---|---|---|
| COPSBenchmark.jl | Julia | COPS benchmark suite implemented in JuMP — standard NLP solver stress tests |
| LuksanVlcekBenchmark.jl | Julia | Lukšan–Vlček nonlinear test problems |
| MadNLPBenchmark.jl | Julia | MadNLP's own benchmark harness |
| MPCCbenchmark.jl | Julia | MPCC benchmark problems |
| MIPLIB.jl | Julia | MIPLIB instance access |
| ExaModelsPower-benchmarking-archive | MATLAB | Frozen (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
| Work | Why |
|---|---|
| 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), 2009 | Not 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 Manual | The 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
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| Arrillaga & Watson, Power System Harmonics (Wiley, 2nd ed. 2003) | The harmonics row: per-frequency network models, source characterization and penetration studies |
Reliability and adequacy
| Work | Why |
|---|---|
| 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
| Work | Why |
|---|---|
| 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), 2010 | The 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), 1967 | The 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-abiC ABI from a Rustcdylib, 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:
| Section | What it covers |
|---|---|
| §1–3 | Generating a synthetic radial MV/LV benchmark grid, then timing gridoxide and power-grid-model on it |
| Interpreting results | How to read the numbers, including the cold-vs-warm distinction |
| §4 | The 12-case real IEEE/MATPOWER test-case suite, against five other solvers |
| §4b | Batched power flow — the multi-core CPU baseline (batch::BatchSolver) |
| §4c | The JAX oracle validating the block-diagonal embedding |
| §4d | Block-diagonal embedding on real sparse code |
| §5 | Cross-validating CGMES import against pypowsybl |
| §6 | The CGMES conformance test configurations |
| §7 | State estimation — both gridoxide methods against both power-grid-model methods |
| §8 | DC (Bθ) power flow against the AC solve, and the cost of a PTDF/LODF column |
| §9 | AC N-1 contingency screening: factorization reuse across an outage sweep |
| §10 | Node-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,
Kluis 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.
| buses | units | unknowns | build | 3 s run | per step | Newton/step |
|---|---|---|---|---|---|---|
| 16 | 8 | 112 | 0.7 ms | 30 ms | 0.050 ms | 1.34 |
| 64 | 32 | 448 | 0.15 ms | 160 ms | 0.266 ms | 1.34 |
| 256 | 128 | 1 792 | 0.5 ms | 682 ms | 1.14 ms | 1.34 |
| 1 024 | 512 | 7 168 | 2.1 ms | 3.15 s | 5.25 ms | 1.34 |
| 4 096 | 2 048 | 28 672 | 9.2 ms | 16.4 s | 27.3 ms | 1.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 package | License | Translated into |
|---|---|---|
AMD | BSD-3-Clause | amd/aat.rs, amd/core.rs, amd/postorder.rs, amd/mod.rs |
BTF | LGPL-2.1-or-later | btf/maxtrans.rs, btf/strongcomp.rs, btf/mod.rs |
KLU | LGPL-2.1-or-later | analyze.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:
| Package | License |
|---|---|
AMD | BSD-3-Clause |
COLAMD | BSD-3-Clause |
BTF | LGPL-2.1-or-later |
KLU | LGPL-2.1-or-later |
SuiteSparse_config | BSD-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.
- Upstream: https://github.com/m-mirz/cimoxide
- Crate:
cimoxide-model0.6.0
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:
- Release the new version from the cimoxide repo (
make generate, thenmake build && make testbefore publishing — the generated class tables are what a schema change moves). - Bump the
version =value in this repo'sCargo.tomlfor thecimmodeldependency, and update the version noted above. - Run
cargo build --features cgmesandcargo test --features cgmesto confirm the converter still matchestests/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 whentests/data/CGMES-Test-Configurationsisn't checked out, sogit submodule update --init tests/data/CGMES-Test-Configurationsfirst, 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.