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. gridoxide's own column was re-verified against current source
(every cell claiming support names the function or type implementing it), and the new
"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 pre-existing row were not
re-surveyed and are carried over from the previous revision — treat them as the older snapshot. The
new "Multi-island" row marks the four tools not checked as not surveyed rather than ❌, since
absence of a survey is not evidence of absence of the feature.
Summary table
| Feature | lightsim2grid | power-grid-model | powsybl-open-loadflow | VeraGrid | pandapower | gridoxide (today) |
|---|---|---|---|---|---|---|
| AC power flow (Newton-Raphson) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DC / linear power flow | ✅ | ✅ ("linear" mode) | ✅ | ✅ (SolverType.Linear/LACPF) | ✅ (rundcpp) | ⚠️ only as an internal initial guess (network::linear_initial_guess), not a standalone mode |
| 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) | ✅ solver::newton_raphson_enforcing_q_limits (opt-in; plain newton_raphson still ignores q_min/q_max) |
| Distributed slack (multi-bus) | ✅ | ❌ | ✅ + area-interchange control | ✅ PowerFlowOptions.distributed_slack | ✅ distributed_slack + per-generator slack_weight | ❌ single slack only |
| 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, and static: RegulatingControl.Terminal resolves to the controlled bus, which is pinned to PV at the target (src/cgmes.rs, both SynchronousMachine and StaticVarCompensator). No control loop — the assignment happens once at import |
| 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 | ❌ | ❌ last writer wins: each regulating machine overwrites voltage_mag/q_min/q_max on the controlled bus, so two controllers with different targets silently keep only the last, with no Q split and no conflict diagnostic |
| 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) | ❌ static taps only |
| 3-winding transformers | ❌ absent | ✅ (star-equivalent via 2 legs) | ✅ | ✅ (Devices/transformer3w.py, plus a generic N-winding transformerNw.py) | ✅ (create_transformer3w) | ✅ (already have a passing test fixture) |
| 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) | ⚠️ consumed, not modeled: cgmes::merge_closed_switches union-finds buses across closed Breaker/Disconnector/LoadBreakSwitch/Fuse/Jumper/Cut/GroundDisconnector/DisconnectingCircuitBreaker, honoring open + inService. No switch element in gridoxide's own network model, so switching state can't be changed between solves |
| 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 | ✅ 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) | ✅ (already solving, tested against PGM fixtures) |
| 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 | ❌ — batch::BatchSolver solves the batch shape (see the row below), but Scenario::branch_outages is a declared seam that returns BatchError::OutagesUnsupported: an outage changes the Y-bus, and therefore the one sparsity pattern the batch's shared symbolic factorization is built around |
| 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 (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 |
| 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) | ❌ |
| 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) | ⚠️ symmetric only (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; reads asymmetric sensors but reduces them to the symmetric problem) — see the note below |
| 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 | ❌ / ❌ |
| 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 | ❌ |
| Dynamic / time-domain simulation (EMT, RMS, small-signal stability) | ❌ | ❌ | ❌ | ✅ (Simulations/EMT/, Simulations/Rms/, Simulations/SmallSignalStabilityEmt/+SmallSignalStabilityRms/ — the only one of the six with this at all) | ❌ | ❌ |
| 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.
- 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 —solver::newton_raphson_enforcing_q_limitsimplements 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. - 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. What remains is the part the batch fast path deliberately excludes: a branch outage gives each scenario its own Y-bus and therefore its own sparsity pattern, soScenario::branch_outagesis currently a documented error rather than a solve. - Distributed slack — 4 of 5 tools have it (only lightsim2grid, powsybl, VeraGrid, and pandapower); real transmission grids often split slack across several generators.
- DC power flow as a first-class mode — cheap, since
linear_initial_guessis most of the way there already; every reference tool treats this as a basic offering. - 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.
- Everything else in the table (TCSC/SSC and the wider FACTS set, short-circuit, sensitivity/OPF, outer-loop/controller architecture as a general extensibility mechanism, VeraGrid's unique EMT/RMS dynamic simulation, pandapower's protection-device modeling) — real capabilities elsewhere, but either a materially larger undertaking or outside gridoxide's current scope as a focused AC power-flow library.
Note on state estimation
Done for symmetric estimation — snapshot and batched, with voltage, power and current sensors — and for asymmetric networks driven by symmetric sensors. 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.
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, 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. Substantially done, and what remains is narrower than the heading suggests.
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, andmeasurement::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.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.pgm_3ph_mapsrefuses the components the three-phase conversion does not model —link,three_winding_transformer,voltage_regulator, and any transformer winding pair outside Dyn and YNyn — with a typed error rather than dropping them silently or, in the last case, panicking from insidetransformer_seq_params. -
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(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: every benchmark and fixture here estimates from data that is either perfectly consistent or hand-authored. Nothing in this repo generates realistically noisy or corrupted measurements, so gridoxide's bad-data advantage — lead 1 — has never actually been measured against anything. Bad-data behaviour needs a harness that does not exist yet.
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 newton_raphson_enforcing_q_limits 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.