Validation
This chapter says what is validated against what, to which tolerance, and how
to re-run each check. Treat every tolerance here as a maintainer
expectation: if your change makes any of them worse, the change is wrong
until proven otherwise. The full narrative with captured result tables is
docs/benchmarking.md; this chapter is the map of the instruments.
The validation stance is two-layered by design. The primary instruments are independent NumPy re-derivations that ask "does the answer satisfy the equations?" - a stronger question than "do two iterative solvers agree?", and one that stays valid where a second solver cannot converge from a flat start. The corroboration instruments diff GridSim against named industry tools (pandapower, scipy) where those tools converge.
case9 against MATPOWER
The WSCC 9-bus case (IO/IeeeCases.Case9()) is the physics ground truth. Its
solve is validated against the published MATPOWER solution: converged in
4 iterations to a max mismatch of 1.84e-14 pu, total real loss
~ 4.641 MW (tests/GridSim.Tests/PowerFlowTests.cs; docs/benchmarking.md
sec. 2a canonical figures). Its dynamic data is the published Anderson & Fouad
WSCC set - H = 23.64 / 6.40 / 3.01 s on MBase 247.5 / 192 / 128 MVA, system
total ~ 7.465 GVA.s - pinned by tests/GridSim.Tests/Case9InertiaTests.cs
(three facts: per-generator H/MBase, per-generator stored energy, the system
total). Inertia does not enter the load flow; the pin protects the frequency
work.
The independent residual check (power flow)
tools/crossval/residual_check.py rebuilds the bus admittance matrix from the
exported branch/shunt data with the standard MATPOWER pi-model - in NumPy, a
different language and codebase - evaluates
S = V ⊙ conj(Y*V) * baseMVA at every bus from GridSim's solved voltages, and
compares it to the specified injections, additionally checking every PV bus's
implied reactive output against its aggregate Q-limits. Expectation: every
case solves to a machine-epsilon residual (worst mismatch anywhere across
the 14-case desktop ladder, including the 3,539-bus gb-full, is ~ 2e-7 MW; a
tolerance of 1e-6 MVA on the 100 MVA base is the pass line) with zero
Q-limit violations (docs/benchmarking.md sec. 2a).
gridsim crossval <case|file.m|case-dir> --tol 1e-6 # solve + export + both checks
python tools/crossval/residual_check.py <solve-export.json> --tol 1e-6
Machine-epsilon pandapower cross-checks
Power flow
tools/crossval/crossval.py (driven by the crossval verb) rebuilds the
network in pandapower 3.4.0, solves with enforce_q_lims=True on both
sides, aligns voltages to the slack, and diffs. Expectations on the reference
cases: max |deltaVm| between 4.4e-16 and 5.5e-12 pu, max |deltaVa| between
1.6e-14 and 2.6e-10 deg (case9/14/30/57/118/145 - docs/benchmarking.md
sec. 2d). Where pandapower disagrees or fails (case24's multi-generator Q-limit
switching, case300 flat-start non-convergence, PEGASE singular Jacobians), the
residual check arbitrates - in each captured instance GridSim's solution
satisfies the equations. tests/GridSim.Tests/CrossvalContractTests.cs locks
the export-JSON field contract in CI so the sidecars cannot silently break;
the Python side is env-gated (absent pandapower degrades gracefully).
State estimation
tools/crossval/crossval_estimation.py feeds pandapower.estimation (WLS,
flat start, tol 1e-8) the identical measurement set and diffs the estimated
states slack-aligned. Expectations on the seeded synthetic sets
(--synth --seed 20260713, full coverage, nominal noise): max |deltaVm|
<= ~2.3e-13 pu, max |deltaVa| <= ~2e-11 deg on case14/30/118. --bad-data
corroborates identification: a +20sigma gross error injected into a case14 flow
makes both tools' chi-square reject and both remove exactly that measurement
(docs/benchmarking.md sec. 2e). tests/GridSim.Tests/EstimateExportContractTests.cs
locks gridsim-estimate-export/1 in CI.
The scipy RK45 swing agreement
tools/crossval/crossval_freq.py re-integrates the identical swing physics
from a gridsim-frequency-export/1 document with scipy.solve_ivp (RK45) and
compares trajectories. Expectation: the 20 ms explicit-Euler trace matches the
high-order integrator to < 2e-4 Hz - captured: initial RoCoF -0.165 vs
-0.16484 Hz/s (delta 1.6e-4), nadir 49.2420 vs 49.2418 Hz (delta 2.2e-4)
(docs/benchmarking.md sec. 2d). The export carries the discrete recurrence in
plain text precisely so this re-derivation needs no GridSim code.
The independent estimation instrument - se_residual_check.py
tools/crossval/se_residual_check.py is the estimator's primary instrument:
it validates a gridsim-estimate-export/1 document with no GridSim math
involved (it borrows only residual_check.py's independent Ybus/pi-model).
Four checks (se_residual_check.py docstring):
- Honesty - the reported residuals equal
z - h(x^)with h evaluated from the independently built Ybus (expectation ~1e-12). - Objective - the reported J recomputes exactly as
sum (r_i/sigma_i)^2. - Optimality - x^ is the true WLS minimum: one independent Gauss-Newton
step
deltax = (H^TWH)^-1H^TW(z - h(x^)), with a finite-difference Jacobian (no shared analytic algebra), moves ||delta-x||inf ~ 1e-11. - Leverage (
--leverage) - re-derives the residual-covariance diagonal (K_ii, Omega_ii = sigma_i^2(1 - K_ii)) and arbitrates the export's criticality flags. This mode caught a real pivot-order bug in the estimator's factor-once LU on its first day of use - keep it in the loop when touching the bad-data path.
gridsim estimate case118 --synth --out results/est
python tools/crossval/se_residual_check.py results/est --tol 1e-6 --leverage
Exit codes: 0 all checks pass, 1 mismatch, 3 unreadable.
The Monte-Carlo sweep
tools/crossval/mc_estimate_sweep.py sweeps seeds x noise scale x measurement
coverage through estimate <case> --synth (default case118, 10 seeds per
configuration; the sampler scores each estimate against its own sampled truth)
and aggregates error statistics. Three properties are the expectations
(docs/benchmarking.md sec. 2g; raw table
tools/crossval/mc_estimate_sweep_case118.json):
- error scales linearly with noise (x2 noise -> ~x2.3 error);
- accuracy degrades gracefully with redundancy (dof 863 -> 30 costs only ~x2 in error);
- the chi-square gate is honest - measurements corrupted to twice their declared sigma fail consistency 10/10 while every honestly-declared configuration passes 10/10.
python tools/crossval/mc_estimate_sweep.py # case118, 10 seeds
python tools/crossval/mc_estimate_sweep.py --case case30 --seeds 5
Agreement tests - dense<->sparse, method<->method, and the bitwise gate
These run inside the ordinary test suite and pin the claim that alternative numerical paths are the same physics:
- Dense <-> sparse power flow. Where both engines solve a case they agree on
iterations and mismatch (
benchreports both; the desktop matrix is the captured evidence), and the sparse path's solutions pass the same residual check. GPU solve exports are bit-for-bit the CPU's. - Dense <-> sparse estimation.
tests/GridSim.Tests/SparseEstimatorTests.csforces the sparse backend (SparseBusThreshold = 0) on the standard cases and asserts: exact-truth recovery to < 1e-9 pu / 1e-8 deg, dense-vs-sparse agreement on a noisy set to < 1e-8 pu / 1e-7 deg with matching J and chi-square verdicts, and identification of the same gross error. The sparse observability report is honest about its weaker proof (rank = -1, island sweep instead of a rank certificate). - Method <-> method. Also in
SparseEstimatorTests.cs: Huber matches WLS on clean data and shrugs off multiple gross errors; LAV estimates through corruption;--method constrainedhonoursvirtual-zero-injectionrows exactly (hard KKT equalities) while agreeing with WLS elsewhere. - The bitwise injection-agreement gate.
SparseEstimatorTests.Neighbour_injection_evaluation_is_bit_identical_to_the_solver_pathpins that the measurement model's neighbourhood-only injection evaluation equals the full-scan solver injections bit for bit (Assert.Equalon doubles, no tolerance) at an off-solution state. The skipped terms are exact structural zeros, so any drift between the estimator's h(x) and the solver's power equations is a copy-divergence bug - this test makes such a divergence a hard failure, not a slow numerical erosion. Preserve it as an exact equality; loosening it to a tolerance defeats its purpose. - Export determinism.
SolveExportTestsassert the recorder is observational (bit-identical voltages with and without a trace) and the JSON byte-for-byte reproducible for a deterministic solve.
The test suite
dotnet test tests/GridSim.Tests # expect 552 passing
552 tests, 0 failed/skipped (verified on this tree; ~20 s). The suite is
70 fixture files under tests/GridSim.Tests/; the larger fixtures and what
they guard:
| Fixture | Guards |
|---|---|
BridgeProtocolTests (17) |
Wire-message parsing, model rebuild, slim-state reconstruction, correlation ids |
GridModelValidateTests (15) |
Model validation diagnostics |
SolverPropertyTests (13) |
Physical invariants any converged solution must obey: power balance, non-negative losses, slack closure, dense<->sparse agreement, bit-for-bit determinism |
WlsEstimatorTests (11) |
The estimation loop: convergence, chi-square, determinism, priors |
SparseEstimatorTests (10) |
Dense<->sparse/method<->method agreement + the bitwise injection gate |
MatpowerParserTests (10) |
.m parsing and unit normalisation |
InertiaAndLimitsTests / FrequencyIntegratorTests (10 each) |
Inertia estimation, limit ladder, integrator behaviour |
GdaPathsTests / ForwardInferenceGuardTests (9 each) |
GDA path resolution; the 169-hour horizon (reject/accept boundaries) |
AnalysisCoverageTests (9) |
Contingency/boundary analysis coverage |
QLimitRevertTests (8) + QLimitTests |
PV->PQ switching and reversion |
StabilityMetricsTests (7) |
Purchased time / MIF tables reproduced from the published corpus |
EstimateExportContractTests, CrossvalContractTests |
The sidecar JSON contracts, locked without Python |
EventCatalogTests, ReplayValidatorTests, EvidenceReportTests, ScenarioEngineTests |
The evidence chain and its reproduced-baseline gate |
Distributed*Tests (6 fixtures) |
The distributed inertia model: exact reduction to lumped, invariants, qualitative behaviour, scale divergence |
The pedantic build is part of validation: dotnet build GridSim.slnx -c Release must produce 0 warnings / 0 errors (Directory.Build.props turns
on TreatWarningsAsErrors + .NET analysers).
bench and the expected-non-convergence list
gridsim bench sweeps every case x every available engine (dense, sparse,
BFS, --gpu adds CUDA/ROCm) with 10 timing runs each, writing Markdown + CSV
run-metadata.json;nminus1-sweep,stressandchainexercise the solver under load (docs/benchmarking.mdsec. 1, sec. 3). When reading a bench report, threeNOclasses are properties of the input, not solver bugs (docs/benchmarking.mdsec. 5): PGLib-OPF operating points (optimisation solutions, not flat-start power flows - their non-OPF twins solve), radial distribution feeders (BFS territory, a scope boundary), and the135/sqrt(3)parser gap in twocase533mtfiles.OOMis a dense-path memory ceiling, recorded only when the allocation actually throws.
gridsim bench # solver x backend matrix
gridsim bench --gpu # add GPU engines when a device answers
gridsim nminus1-sweep --all --sample 3000 --parallel 28
gridsim stress --all --from 0.5 --to 3.0 --step 0.05
gridsim chain case118 --steps 100 --mode fixed
GDA-side validation of the estimated history
The estimated GB history has its own closure instruments, run per backfilled day (details in 09-gda-integration):
Derived/state_divergence- the demand-scaled solve route vs the measurement-fitted estimate route per period; divergence localises model or dispatch error.Derived/estimation_closure- estimated generation/load totals closed against the NESO national demand series, chi-square pass rate, and the systematic-outlier league (repeat-removed measurements feed crosswalk/fold fixes).- Sigma calibration -
GDA NetworkModel/calibrate_sigmas.pyaggregates standardised residuals per measurement source class across eras; the expectation is no overconfident class in the rawr/sigmaview (rms <= ~1.4), with the leverage-corrected floor attributed to network fidelity, not sigma tuning (docs/benchmarking.mdsec. 2f).
Tolerance summary - what a maintainer must preserve
| Check | Instrument | Expectation |
|---|---|---|
| case9 solve | MATPOWER published solution | 4 iterations, 1.84e-14 pu, loss ~ 4.641 MW |
| Any converged solve | residual_check.py |
Machine-epsilon residual (<= 1e-6 MVA gate; worst observed ~ 2e-7 MW), 0 Q-limit violations |
| Power flow vs pandapower | crossval.py |
<= ~1e-12 pu / ~1e-10 deg where pandapower converges |
| Estimation vs pandapower | crossval_estimation.py |
<= ~1e-13 pu / ~1e-11 deg on the seeded synthetic sets |
| Swing vs scipy RK45 | crossval_freq.py |
< 2e-4 Hz on RoCoF and nadir |
| Estimate honesty/optimality | se_residual_check.py |
Residuals ~1e-12; independent GN step ~1e-11 |
| Dense <-> sparse estimation | SparseEstimatorTests |
< 1e-8 pu / 1e-7 deg, same chi-square verdict, same removals |
| Injection evaluation | bitwise gate | Exact (bit-for-bit), no tolerance |
| MC sweep | mc_estimate_sweep.py |
Linear-in-noise error; chi-square 10/10 honest each way |
| Suite | dotnet test |
552 / 552 passing; Release build 0 warnings |
See also
- 03-power-flow.md - the solver whose solutions these instruments check
- 06-state-estimation.md - the estimator, its methods and bad-data machinery
- 09-gda-integration.md - the GDA products the closure reports audit
- 10-file-and-wire-formats.md - the export contracts the sidecars consume
- ../manual/14-validation-tools.md - running crossval, se_residual_check, mc_estimate_sweep and bench as a user
- ../manual/17-troubleshooting.md - the non-convergence list as symptoms -> causes