Technical manual — chapter list

Architecture

This chapter maps the codebase for someone modifying or auditing it: the projects and their dependency directions, the seams (the interfaces the system pivots on and every implementation behind each), the end-to-end data flows, and where each wire format crosses a process or project boundary.

Projects

The solution GridSim.slnx carries six src/ projects and one test project. Dependency facts below are taken from the .csproj files.

Project Kind TFM Purpose References
GridSim.Core class library net10.0 The physics: model, power flow, sparse numerics, dynamics, estimation, evidence, IO, simulation loops, prioritised analysis scheduling. No package references. -
GridSim.Gpu class library net10.0 GPU linear-solver backends (ILGPU/CUDA and ROCm/HIP) behind the Core seams. Core + ILGPU, ILGPU.Algorithms
GridSim.Gda class library net10.0 Ingestion of the GB data lake (NESO + DNO LTDS sources: Ltds/, Neso/, Npg/, Ukpn/, Csv/, Geo/) into GridModels. No grid data is embedded. Core + Parquet.Net
GridSim.Cli console exe net10.0 Every verb (solve, bench, N-1, transient, replay-event, scenario, fleet, evidence, gda-replay, estimate, crossval, ...), backend wiring, run metadata. Core, Gda, Gpu
GridSim.Wpf WinExe net10.0-windows The desktop dashboard (MVVM; tabs NETWORK -> CYCLE -> ASSETS, transport bar, inspector, bridge/replay pickers). Core
GridSim.Web web exe net10.0 Blazor Server dashboard plus a REST/WebSocket API over Core (Api/, Components/, Projection/, Sessions/, Model/). Core
tests/GridSim.Tests xUnit net10.0 The test suite (case9 validated against the published MATPOWER solution and cross-checked against pandapower). Core, Gda, Web

Dependency directions are strictly one-way, with Core at the root:

                GridSim.Core          (Model, PowerFlow, Numerics, Estimation, Dynamics,
               /   |   |   \  \        Analysis, Simulation, Scheduling, IO, Net, Events, Evidence, ...)
              /    |   |    \  \
        Gpu     Gda   Wpf   Web  Tests
          \      |               (Tests also references Gda and Web)
           \     |
            GridSim.Cli           (argv parsing, backend selection, drives everything)

Two consequences to preserve when you change things:

  • Core has zero package references and no UI/OS dependencies beyond the BCL (its bridge client uses System.Net.WebSockets directly). Anything that needs a NuGet package or a device driver goes in a leaf project behind a Core seam.
  • Nothing references GridSim.Gpu except the CLI. The GPU backends reach the solvers only through ILinearSolver/ISparseLinearSolver, so Core never needs to know CUDA or HIP exists.

The seam catalogue

A "seam" is an interface deliberately placed where an alternative implementation is expected. Each is listed with its contract, its implementations, and who owns the call site.

ILinearSolver - dense linear solve

src/GridSim.Core/Abstractions/ILinearSolver.cs:ILinearSolver. Solves the dense real system Ax = b in place (SolveInPlace(double[,], double[]); the caller's buffers are consumed - this is the allocation-free path the Newton-Raphson inner loop uses). Every dense Newton iteration and every dense estimator gain solve goes through here.

Implementation File Notes
DenseLuSolver src/GridSim.Core/PowerFlow/DenseLuSolver.cs Default. Partial-pivoting Gaussian elimination over a flat row-major copy with SIMD row updates (DenseLuKernels.SubtractScaledRow); bit-for-bit identical to the scalar textbook order. Throws on a near-singular pivot.
GpuDenseLinearSolver src/GridSim.Gpu/GpuDenseLinearSolver.cs ILGPU elimination over an augmented [A|b] matrix, AcceleratorType-selectable (CUDA or OpenCL - same kernels); falls back to DenseLuSolver when no such device exists.
RocmDenseLinearSolver src/GridSim.Gpu/RocmDenseLinearSolver.cs Native hipSOLVER Dgetrf/Dgetrs (explicit workspace) via P/Invoke; falls back to DenseLuSolver when no HIP device exists.

ISparseLinearSolver - sparse linear solve

src/GridSim.Core/Abstractions/ISparseLinearSolver.cs:ISparseLinearSolver. double[]? Solve(CsrMatrix a, double[] b) - returns the solution or null when the matrix is structurally/numerically singular, in which case the sparse Newton solver falls back to its ILU(0)/BiCGSTAB path.

Implementation File Notes
CpuSparseLuSolver src/GridSim.Core/PowerFlow/CpuSparseLuSolver.cs Default. Wraps SparseLu.Solve (Gilbert-Peierls LU + RCM ordering); the reference the GPU sparse backends are validated against.
GpuSparseLinearSolver src/GridSim.Gpu/GpuSparseLinearSolver.cs ILGPU Jacobi-preconditioned BiCGSTAB, AcceleratorType-selectable (CUDA or OpenCL - same kernels); returns null on breakdown so the caller's fallback contract still holds.
RocmSparseLinearSolver src/GridSim.Gpu/RocmSparseLinearSolver.cs Native ROCm: fully device-resident BiCGSTAB (rocsparse_dcsrmv SpMV + rocBLAS-1 vector algebra + rocblas_ddgmm Jacobi apply); ABI matched to ROCm 6.x, awaiting first Instinct runtime run.

IBatchedLinearSolver - many same-sized dense solves in one dispatch

src/GridSim.Core/Abstractions/IBatchedLinearSolver.cs:IBatchedLinearSolver. bool[] SolveBatch(int count, int n, double[] aFlat, double[] bFlat) - solves count independent n x n systems in one call over row-major flat buffers; the solution overwrites each system's bFlat segment and the per-system success mask is returned (a singular system's segment is filled with NaN and reported false - one bad system never fails the whole batch). The point is amortising per-solve launch and global-lock overhead across a batch, so a contingency sweep of many same-sized outages becomes a handful of dispatches instead of hundreds of locked single solves.

Implementation File Notes
CpuBatchedLuSolver src/GridSim.Core/PowerFlow/CpuBatchedLuSolver.cs Reference/fallback: loops DenseLuSolver across the batch in parallel. The ground truth the GPU path is validated against.
GpuBatchedDenseSolver src/GridSim.Gpu/GpuBatchedDenseSolver.cs ILGPU batched Gaussian elimination with partial pivoting (the GpuDenseLinearSolver algorithm with a batch index threaded through every kernel): one flat augmented buffer factored under a single GpuContext.Lock acquisition and one pair of host syncs. Falls back to CpuBatchedLuSolver when no device is present, so it is always safe to construct. ROCm has no native batched entry point yet, so it uses the CPU reference.

src/GridSim.Cli/SolverBackend.cs:SolverBackend.DenseBatch() vends the batched solver for the selected backend (CUDA/OpenCL -> GpuBatchedDenseSolver, else CpuBatchedLuSolver); the CLI batchsolve verb exercises it against the CPU reference.

IPowerFlowSolver - the whole steady-state solve

src/GridSim.Core/Abstractions/IPowerFlowSolver.cs:IPowerFlowSolver. PowerFlowResult Solve(GridModel, PowerFlowOptions?).

Implementation File Chosen when
NewtonRaphsonPowerFlow src/GridSim.Core/PowerFlow/NewtonRaphsonPowerFlow.cs Meshed networks up to the sparse threshold (3,000 buses).
SparseNewtonRaphsonPowerFlow src/GridSim.Core/PowerFlow/SparseNewtonRaphsonPowerFlow.cs Above the threshold.
BackwardForwardSweep src/GridSim.Core/PowerFlow/BackwardForwardSweep.cs Radial / weakly-meshed distribution feeders.

src/GridSim.Core/PowerFlow/SolverSelector.cs:SolverSelector is the single source of truth for the choice (see 03-power-flow.md); the CLI and both dashboards route through it so a given case always solves with the same engine. SteadyStateSolver wraps any IPowerFlowSolver in the OLTC outer loop.

PowerFlowOptions.Cancellation (a CancellationToken, default None) lets a deferrable solve abort at a cycle deadline: NewtonRaphsonPowerFlow and SparseNewtonRaphsonPowerFlow check it at the top of every inner iteration. default/None is a guaranteed no-op (ThrowIfCancellationRequested never throws for it), so every existing solve is bit-for-bit unchanged - a test asserts exact Vm/VaDeg equality between the token and no-token paths. ContingencyAnalysis.ScreenBranchesParallel takes the same optional token on its ParallelOptions so a deferred N-1 shard aborts between outages.

IInertiaModel - frequency dynamics

src/GridSim.Core/Abstractions/IInertiaModel.cs:IInertiaModel. Turns a model, a demand baseline and power-step events into a FrequencyResponse, plus a CoiInertiaGvaS aggregate for scalar readouts and an optional LastDistributedResult for spatial detail.

Implementation File Notes
LumpedScalarInertiaModel src/GridSim.Core/Dynamics/LumpedScalarInertiaModel.cs Default; single swing equation on the system aggregate.
DistributedInertiaModel src/GridSim.Core/Dynamics/Distributed/DistributedInertiaModel.cs Experimental multi-domain model; returns the COI-collapsed response so scalar consumers keep working.

Selected at runtime by src/GridSim.Core/Dynamics/InertiaModelSelector.cs:InertiaModelSelector (CLI flag --inertia-model=lumped|distributed).

IWlsStep - the estimator's step backend

src/GridSim.Core/Estimation/WlsStep.cs:IWlsStep (internal). The WlsStateEstimator owns the loop (start point, trust-capped damping, convergence, chi-square, bad-data removal); everything representation-dependent - how H is held, how the gain matrix is formed and solved, how observability is gated, how the residual covariance is computed - lives behind this seam. Contract: Begin (bind to a fixed model/measurements/weights, do structural work once per pass), GateObservability, TrySolve (one undamped Gauss-Newton correction; false = singular gain), NormalizedResiduals.

Implementation Notes
DenseWlsStep Dense normal equations G = H^TWH on ILinearSolver; at or below the sparse threshold. Proves observability by gain-matrix rank.
SparseWlsStep CSR gain + Gilbert-Peierls LU; the gb-full-scale backend. Builds the gain's sparsity pattern, per-row slot maps and the RCM ordering once in Begin, then only refills values per iteration (SparseLu.Ordering + SparseLu.FactorPrepared).
ConstrainedDenseWlsStep --method constrained: rows tagged virtual-zero-injection become hard equalities via a KKT (Hachtel-style) augmented system. Dense only; above the threshold it throws rather than silently degrading.

The robust methods (Huber, LAV) are not separate steps: the estimator wraps the same step backend in an IRLS reweighting outer loop (src/GridSim.Core/Estimation/WlsStateEstimator.cs:WlsStateEstimator.Estimate).

IJacobianSink - one Jacobian evaluation path, two storages

src/GridSim.Core/Estimation/JacobianSinks.cs:IJacobianSink (internal). MeasurementModel.EvaluateJacobian computes each measurement partial exactly once and pushes it into a sink, so the dense and sparse estimator backends share a single evaluation path (no copy-drift):

  • DenseJacobianSink - writes into a caller-cleared dense double[m,s].
  • SparseJacobianRows - collects per-measurement sparse rows (an injection row touches only its bus's electrical neighbourhood, a flow row four columns, a voltage row one).

Bridge sources - which reconstruction streams

Not a C# interface but a wire-level seam: every bridge stream carries a source string, live | presolved | estimated (src/GridSim.Core/Net/GridBridgeClient.cs:GridBridgeClient.StartAsync, default presolved).

  • live - the server solves per tick.
  • presolved - replays the demand-scaled pre-solved lake frame by frame.
  • estimated - replays the measurement-conditioned WLS estimate lake; each state frame then carries a per-frame estimation-quality block (BridgeProtocol.EstimateBlock: objective J, chi-square verdict, redundancy, removals) so a view can show how well the frame fitted its telemetry.

The WPF SRC picker (src/GridSim.Wpf/MainWindow.xaml.cs, _bridgeSource, launch arg --bridge-source) and the web's bridge:<case>@<source> case-id suffix (src/GridSim.Web/Sessions/SessionManager.cs) select it; the server echoes the choice back in StateMessage.Source.

Compute backends - CPU/CUDA/ROCm selection

src/GridSim.Cli/SolverBackend.cs:SolverBackend maps the runtime switch (--cpu default, --gpu = AMD-first auto-detect ROCm -> OpenCL -> CUDA -> CPU, or explicit --gpu=rocm|opencl|cuda) onto the dense and sparse linear-solver seams and records the choice in RunInfo so reports are comparable. An unavailable accelerator warns and falls back to CPU; availability probes (GpuContext.IsAvailable, RocmContext.IsAvailable) never throw. See 04-sparse-and-gpu-numerics.md for why the CPU backends are the production path.

Data flow: case -> solve -> frames -> views

  1. Case sources. A GridModel arrives from one of: built-in factories (src/GridSim.Core/IO/IeeeCases.cs - case9 is the physics ground truth); the MATPOWER parser (src/GridSim.Core/IO/Matpower/MatpowerParser.cs) for any .m file; a JSON case directory (src/GridSim.Core/IO/Json/JsonGridLoader.cs:JsonGridLoader.LoadDirectory
    • system.json, buses.json, generation.json required; demand.json, transmission.json, transformer.json, onloadtapchanger.json optional), which covers the GDA-materialised gb-full/gb-spine/zone cases; the GDA builder itself (src/GridSim.Gda/GdaModelBuilder.cs); or a bridge topology message.
  2. Solve. SolverSelector.Create picks the engine; SteadyStateSolver runs it inside the OLTC loop and exposes RegulatedModel (the model whose tap ratios match the result - the loop solves internal clones, so consumers needing post-solve taps must read it, never the input model). The output is a PowerFlowResult.
  3. Frames. Three producers assemble SimFrame(Model, Result, Hour, Tick, FrequencyHz, GenerationMw, DemandMw, RocofHzPerS) (src/GridSim.Core/Simulation/LiveSim.cs:SimFrame): LiveSim (steps a day, re-dispatches, re-solves, integrates frequency); PresolvedReplay (src/GridSim.Core/Simulation/PresolvedReplay.cs - loads a run directory of exports/*.json solve-exports into a frame cursor, telemetry from the sidecar CSV where present); and GridBridgeClient (rebuilds frames from streamed state messages against the cached topology).
  4. Views. The WPF shell binds tabs to the current frame (src/GridSim.Wpf/DashboardViewModel.cs); the web re-projects each frame into per-tab DTOs on the server (src/GridSim.Web/Projection/FrameProjector.cs) rather than serialising view models. All three frame producers feed the same views - replay and bridge modes are branches in the shell, not separate UIs.

Data flow: case -> solve -> cycle fan-out -> telemetry

The base Newton-Raphson solve is single-core, but everything a control room needs after it converges (N-1 sweeps, harmonics, RoCoF screening, physics conformance, the watch list) reads the same solved frame and is embarrassingly parallel. The GridSim.Core.Scheduling namespace turns that into one base solve plus a prioritised parallel fan-out per cycle:

  1. One base solve. CycleRunner drives LiveSim.Step (the single-core NR solve) then hands the immutable SimFrame (GridModel + PowerFlowResult, both immutable-by-convention, so sharing it to every job is a reference, not a deep copy - jobs treat the bare double[] arrays read-only) to CycleScheduler.RunCycle.
  2. Fan-out. The frame's post-solve work is expressed as AnalysisJobs - small units with a stable Id, a JobPriority (High = must complete every cycle; Low = deferrable/best-effort) and a SeedCoreMs cost seed. AnalysisJobLibrary wraps the existing analyses as jobs, nothing re-implemented (ContingencyAnalysis.ScreenBranchesParallel as a hot High set plus rotating Low branch-index shards, HarmonicPenetration.Solve, RocofScreening.Screen, PhysicsLaws.Audit, WatchList.Assess).
  3. Budget. Currency is core-milliseconds: W workers over a windowMs window own W x windowMs of compute (CycleBudget, CycleOptions.WindowMs/Workers). CycleScheduler drains High-before-Low over a worker pool (a Parallel.For over an atomic cursor; not re-entrant - cycles are sequential, the fan-out inside a cycle is parallel). High always completes and is never cancelled; Low is admitted only while budget remains and any Low not served keeps its place at the front of the rotating pool, so later cycles cover it (round-robin, no starvation). JobCostModel keeps a per-job EWMA of measured core-ms feeding admission.
  4. Telemetry. CycleResult reports per-cycle base-solve ms, fan-out wall ms, High done/total, Low admitted/completed/deferred/cancelled, budget total/spent core-ms, utilization, backlog depth, N-1 shard coverage and worst contingency.

ResidentCycleService is the host-agnostic background driver both dashboards wrap (so the engine and its telemetry are shared, with no WPF<->web copy-drift): a UI hands each solved frame to Offer(frame, baseMs); it runs the scheduler off the caller's thread, coalesces to the newest frame, publishes the latest CycleResult plus rolling shard coverage, and raises Updated when a cycle completes. A new CYCLE tab renders it in both shells - the Blazor web dashboard (src/GridSim.Web/Components/Tabs/CycleTab.razor) and the WPF desktop app (src/GridSim.Wpf/Views/CycleVm.cs + CycleView.xaml, refreshed on the dispatcher from the Updated event) - showing the High-tier-met/utilization hero, a budget-accounting card and a High-first job table. The CLI cycle verb is the soak driver (it exits non-zero if High is ever missed); Cycle: web config gates it (Cycle:Enabled, default true for live sessions).

Data flow: measurements -> estimate -> lake -> bridge

  1. Measurements. A gridsim-measurements/1 JSON document is loaded by src/GridSim.Core/Estimation/MeasurementSet.cs:MeasurementSet.Load, which rejects unknown schemas and - like every dated input - checks TakenUtc against the historical horizon (src/GridSim.Core/Diagnostics/ForwardInferenceGuard.cs:ForwardInferenceGuard.HorizonHours, 169 h): the engine reconstructs the past only. Synthetic sets for testing come from SyntheticMeasurements (estimate --synth).
  2. Estimate. The estimate verb builds a MeasurementModel and runs WlsStateEstimator (method/backends per the IWlsStep seam above), producing an EstimationResult with residuals, chi-square verdict, observability report and removals.
  3. Export. EstimateExport writes the gridsim-estimate-export/1 document; with --export-solve the CLI also writes estimated-solve-export.json - an ordinary gridsim-solve-export/1 document of the estimated state, so all replay/bridge plumbing consumes estimated states unchanged.
  4. Lake and bridge. The GDA pipeline batches those estimates into the lake's Derived/estimated_state product; the bridge server streams them back as source=estimated frames with the per-frame EstimateBlock quality attached, into the same views as any other stream.

Wire formats and the boundaries they cross

Every format is JSON unless noted. "Boundary" names where the bytes change hands.

Schema / format Written by Read by Boundary
gridsim-solve-export/1 src/GridSim.Core/IO/SolveExport.cs (CLI --enableExport, transient, gda-replay, estimate --export-solve); the GDA python exporter src/GridSim.Core/IO/SolveImport.cs; PresolvedReplay; web CaseCatalog disk: CLI run directories <-> WPF/web replay; python <-> .NET
Bridge messages (topology, state, result, info/error) GDA bridge server src/GridSim.Core/IO/BridgeProtocol.cs via GridBridgeClient one WebSocket, python server <-> .NET client (WPF and web). The topology model block and the result payload reuse the solve-export shapes.
gridsim-measurements/1 GDA measurement pipeline; SyntheticMeasurements MeasurementSet.Load (CLI estimate --measurements) disk: lake -> estimator
gridsim-estimate-export/1 src/GridSim.Core/IO/EstimateExport.cs GDA build_estimated_state pipeline; validation tooling disk: estimator -> lake
gridsim-frequency-export/1 src/GridSim.Core/IO/FrequencyExport.cs analysis tooling disk
gridsim-evidence/1 src/GridSim.Core/Evidence/EvidenceReport.cs:EvidenceReport.ToJson downstream consumers of the dossier disk
Event catalogue JSON curated files under data/events/ src/GridSim.Core/Events/EventCatalog.cs disk: recorded-event evidence -> replay gate
MATPOWER .m external tools MatpowerParser disk: MATPOWER ecosystem -> GridSim
JSON case directory synth, GDA materialisation, hand-editing JsonGridLoader.LoadDirectory disk: case library -> any entry point
REST /api/* + WebSocket /ws/sim src/GridSim.Web/Api/RestApi.cs, SimWebSocket.cs external consumers (the Blazor UI drives its SimSession in-process) HTTP/WebSocket out of GridSim.Web

Deserialisation on every inbound path is case-insensitive and tolerant of named float literals (Infinity generator limits) - SolveImport, BridgeProtocol.JsonOptions and MeasurementSet all pin the same options, and both the CLI and the web pin InvariantCulture process-wide so numbers never change meaning with the host locale.

See also