User manual — chapter list

Extending GridSim

GridSim's extension points are deliberate seams: implement one interface, register it in one place, copy an existing implementation for the shape. This chapter names each seam, the wiring point, and the implementation to copy - depth lives in the technical manual and the code itself.

A solver / compute backend

Seams (src/GridSim.Core/Abstractions/):

  • ILinearSolver - dense: void SolveInPlace(double[,] a, double[] b) (solution written into b; may destroy a). Throw on a singular matrix.
  • ISparseLinearSolver - sparse: double[]? Solve(CsrMatrix a, double[] b). Return null on singular/breakdown/non-finite - never throw, never return garbage; the caller falls back to ILU0/BiCGSTAB.
  • IPowerFlowSolver - the whole-engine seam; you rarely need it.

Wire it in src/GridSim.Cli/SolverBackend.cs: add your vendor to the Vendor enum, resolve it in Select(string? spec) (the auto order is AMD-first: ROCm -> OpenCL -> CUDA -> CPU, with a warned CPU fallback), and vend your solvers from the dense/sparse factory switches. Nothing in the power-flow code changes. (If your target is an ILGPU-supported accelerator, you likely don't need a new solver at all - the existing GpuDense/GpuSparse solvers take an AcceleratorType, which is how OpenCL reuses the CUDA kernels.)

Copy src/GridSim.Gpu/RocmDenseLinearSolver.cs and RocmSparseLinearSolver.cs, plus RocmContext.cs for the never-throwing availability probe and the device lock (one GPU, many Parallel.ForEach workers - serialise every device touch).

Prove it with gridsim gpucheck (per-call preflight), then gridsim bench --gpu=<vendor> against the CPU reference: identical answers, and case9 at ~4.641 MW losses in 4 iterations. The CPU solvers are the oracle for PowerFlowTests/SparsePowerFlowTests/QLimitTests.

A batched dense solver

For solving many same-sized systems in one dispatch - the shape a contingency sweep of same-sized outages takes, turning hundreds of locked single solves into a handful of batched ones.

Seam (src/GridSim.Core/Abstractions/): IBatchedLinearSolver - bool[] SolveBatch(int count, int n, double[] aFlat, double[] bFlat). The matrices and right-hand sides are row-major flat buffers of count contiguous n x n (resp. n) segments; the solution overwrites each system's bFlat segment. Return a per-system success mask - a singular system's segment is filled with NaN and its mask entry false; it must never fail the whole batch. This is the contract the GPU path is validated against, so honour it exactly.

Wire it in src/GridSim.Cli/SolverBackend.cs: DenseBatch() returns the batched solver for the selected backend (CUDA/OpenCL vend GpuBatchedDenseSolver, everything else CpuBatchedLuSolver). A GPU implementation must fall back to the CPU reference when no device is present, so it stays safe to construct unconditionally (ROCm has no native batched entry point yet, so it uses the CPU batched reference).

Copy src/GridSim.Core/PowerFlow/CpuBatchedLuSolver.cs (the reference: DenseLuSolver looped across the batch in parallel) for the shape, and src/GridSim.Gpu/GpuBatchedDenseSolver.cs for the accelerated path - ILGPU Gaussian-elimination-with-partial-pivoting kernels with a batch index threaded through, factoring one flat augmented buffer under a single GpuContext.Lock acquisition and one pair of host syncs (that amortisation across the batch is the whole point).

Prove it with gridsim batchsolve [--count N] [--n M] [--runs K] [--gpu], which solves the batch on the selected backend, verifies against the CPU reference, and reports timing. The oracle is CpuBatchedLuSolver; the tests are tests/GridSim.Tests/Scheduling/BatchedSolverTests.cs (CPU batched == per-system LU, singular isolation, GPU-vs-CPU).

A case source or DNO reader

Seam: a static Load returning LtdsDataset (src/GridSim.Gda/GdaModelBuilder.cs) built from the shared record types in Ltds/LtdsModel.cs (LtdsNode, LtdsCircuit, LtdsTransformer2/3, LtdsDemand, LtdsGeneration). GdaModelBuilder.Build consumes any dataset identically - there is no registry to edit; wire your reader into the gda verb's --dno switch in Program.cs.

The reader's one promise is its unit convention. Tag the dataset ImpedanceConvention.OhmicReferredToGsp (ohms, uS) or PercentOn100Mva, and set TransformerPercentOnRating truthfully - the builder converts, the reader never does. Ratings are MVA (amps -> MVA = sqrt3kVA/1000). Zero-impedance rows become busbar ties (merged), not branches. Read files through CsvTable/DataSourceTable.Open, and route dated files through GdaPaths.PickNewest so the as-of horizon holds.

Copy Ukpn/UkpnSource.cs (percent-on-rating, amps, synthesised nodes) or Npg/NpgSource.cs (Parquet, percent-on-100 MVA). For a new file format rather than a DNO, the template is Core/IO/Matpower/MatpowerParser.cs.

Test like tests/GridSim.Tests/Gda/GdaBuildTests.cs: a small synthetic fixture with a known answer, checked via GdaDiagnostics (islands, unmatched demand/gen first) and a converged in-band solve. Never commit real DNO data - fixtures are synthetic by policy.

A dashboard tab

Seam: a sub-VM class XVm : Notify (the Notify base with Set(ref ...)/Raise is in src/GridSim.Wpf/DashboardViewModel.cs) exposing a per-frame Update(SimFrame f, ..., string clock). Rows are small Notify classes reused frame-to-frame - Update runs on the UI thread, so keep it cheap and allocation-light.

Wire it in three places: a property on the root DashboardViewModel, a call in its Update fan-out, and a <TabItem> in MainWindow.xaml placed immediately before ASSETS. That rule is load-bearing: the shell identifies ASSETS positionally as the last tab (SelectedIndex == Items.Count-1 in MainWindow.xaml.cs), so a tab added after it breaks the explorer. Current order: NETWORK * GENERATION & INERTIA * THE MACHINE * SCADA * OPERATOR * MARKET * NESO RP1 * MECHANICAL * SPEED DOMAINS * CYCLE * ASSETS.

Rules. Read voltages and taps off f.Model/f.Result (post-solve), never a pre-solve model. Non-trivial physics goes in a pure, unit-tested GridSim.Core class (the pattern: Core/Dynamics/StabilityMetrics.cs + StabilityMetricsTests.cs) so the WPF and web faces share one implementation. Show a real solved number, a Core-derived one, or an explicit "-" - never a plausible invention. Clickable rows wrap the row template in a RowLinkBtn-styled Button with a RelativeSource-bound command (a bare MouseBinding does not fire reliably inside a ScrollViewer).

Copy the NESO RP1 tab end to end: Views/NesoRp1Vm.cs, NesoRp1View.xaml, and for the web mirror Projection/TabDtos.cs + TabProjections.cs + Components/Tabs/Rp1Tab.razor (add your DTO to FrameEnvelope and your header before "ASSETS" in Dashboard.razor).

An analysis job (cycle engine)

Post-solve work that should run in the parallel per-cycle fan-out - the same place N-1, harmonics, RoCoF, physics conformance and the watch list run.

Seam (src/GridSim.Core/Scheduling/): an AnalysisJob is a unit of post-solve work with a stable Id, a JobPriority, a SeedCoreMs cost seed, and a Run(CycleContext, CancellationToken) delegate. Read the solved grid off the immutable CycleContext.Frame (the shared SimFrame - GridModel + PowerFlowResult) and treat it strictly read-only; the frame is one shared reference handed to every job, so mutating it corrupts the whole cycle (a no-frame-mutation test pins this). Honour the CancellationToken - a deferrable job should abort promptly at the cycle deadline; existing analyses do this by threading it onto their ParallelOptions.CancellationToken.

Pick the priority deliberately. High work must complete every cycle and is never cancelled (base solve, the hot N-1 set, harmonics, watch list); Low is deferrable and admitted only while the core-millisecond budget remains, keeping its place at the front of the rotating pool if a cycle cannot reach it. Express divisible work as many small jobs with stable ids (n-1:lp:0, n-1:lp:1, ...) rather than one large job - stable ids are what let the scheduler rotate Low shards to full coverage over several cycles without starvation.

Wire it by adding a factory to AnalysisJobLibrary that wraps an existing GridSim.Core analysis as a job (nothing is re-implemented here - the library wraps ContingencyAnalysis.ScreenBranchesParallel, HarmonicPenetration.Solve, RocofScreening.Screen, PhysicsLaws.Audit, WatchList.Assess). The CycleScheduler drains High-before-Low across the worker pool; CycleRunner steps the base solve then the cycle; ResidentCycleService runs it off the UI thread for both dashboards. Your job's cost feeds the JobCostModel EWMA that governs Low admission, so give SeedCoreMs an honest first estimate.

Prove it with gridsim cycle <case> [--window-ms 1000] [--workers N] [--cycles K] [--jobs n-1,harmonics,rocof,physics,watch], which runs the real-time cycle and prints per-cycle base-solve ms, High completion, Low done/admitted/deferred, utilization % and N-1 coverage, and exits non-zero if High is ever missed. The tests are tests/GridSim.Tests/Scheduling/CycleSchedulerTests.cs (budget = workers x window, High-always, rotation coverage, worker-count-independent results, no-frame-mutation, faulted-isolation).

An evidence capability

Seams (src/GridSim.Core/): a new event is a GridEvent in a gridsim-event-catalog/1 file under data/events/ (losses as negative-MW PowerSteps, recorded actions positive, every non-primary figure tagged CuratedPlaceholder with [VERIFY] notes). A new device is a DeviceConfig (Scenarios/ScenarioSpec.cs) - its Apply keeps rotational inertia and FFR as two separate terms; never merge them. New sweep values just extend the SweepAxes lists; ScenarioEngine.Run iterates whatever it is given. A new cost basis follows Evidence/AvoidedCost.FromLfddAvoided.

Wiring: the assembly point is EvidenceReport.Build (Evidence/EvidenceReport.cs), which encodes the non-negotiable order - gate first, counterfactual/risk/fleet/cost only when unlocked. CLI flags live in the four Run* handlers in Program.cs.

Rules that are invariants, not style. The reproduced-baseline gate (ScenarioEngine throws BaselineNotReproducedException; EvidenceReport withholds sections; CLI exits 3). The 169-hour forward-inference horizon on every dated input (ForwardInferenceGuard.RejectFuture; violation = exit 2). Corner scaling never touches initial RoCoF. A 1-unit, zero-stagger fleet must reproduce the single-device simulation exactly - pin that with a test, plus a gate test (an inconsistent baseline must throw) and a horizon test (a too-recent event must throw).

Copy the built-in events in Events/EventCatalog.cs (gb-2019-08-09, gb-largest-loss-1320) and the existing RunScenario/RunEvidence handlers.

An asset-dynamics class (inverter / transformer / cable)

Seams (src/GridSim.Core/Assets/): an asset is modelled by its dynamic behaviour, not just its fuel string. IActiveAssetDynamics.Update(in AssetStepContext) returns an AssetActuation (a swing-power term + trip signals) for inverters; IPassiveAssetDynamics.Advance(...) returns a PlantStateUpdate (tap/rating/thermal) for transformers/cables. Both compose with both inertia models via IGridFrequencySampler (COI for lumped, per-domain for distributed) - a controller always asks for its local frequency.

Author a class, not code. The everyday extension point is data, not a new type: add an entry to data/<case>/asset-classes.json (schema gridsim-asset-classes/1) - a kind (gridFollowingInverter | gridFormingInverter | transformer | line), a flat params bag, and per-parameter provenance. Bind it by putting dynamicsClass inline on a generator/branch, or a bind-by-technology rule in asset-bindings.json. The built-in controllers cover the common devices; the TSGB device is expressible as a class (that equivalence is a good acceptance test).

Wire it (only for a genuinely new behaviour): implement the interface, add the kind to AssetControllerFactory.Create{Active,Passive}, and to ParseAssetKind in JsonGridLoader. The swing term is already plumbed - AssetDynamicsFleet sums it into pAssetFleet in AssetFrequencyDynamics (batch) and AssetAwareFrequencyIntegrator (live). CLI: --asset-dynamics.

Rules that are invariants, not style. Default OFF (DynamicsConfig.EnableAssetDynamics), and an empty/passive fleet appends 0.0 last so the trajectory is bit-identical to the lumped baseline - pin it with a reduction test (mirrors the T0 gate). Every unmeasurable control parameter is CuratedPlaceholder + literal [VERIFY] (enforced at load); the aggregate fleet result is UnvalidatedAtScale, never Recorded. For any real-fleet claim the deliverable is a parameter-sweep envelope (AssetEnvelope.Sweep) + tornado, not a point curve - if the band brackets cascade/no-cascade, that is the finding. Validate against a recorded event (AssetValidation.Compare) before calling output evidence. A grid-forming synthetic-inertia term lowers RoCoF as a power injection and must never be folded into E.

Copy Assets/GridFollowingInverter.cs (the sample→compute→actuate→protect state machine), Assets/Passive/TransformerDynamics.cs (OLTC delay + thermal), and the tests AssetDynamicsReductionTests / InverterTripCascadeTests / AssetEnvelopeTests. The GB gb-full library is autogenerated by GDA's NetworkModel/build_asset_classes.py (grounded dispatch ramps from ramp_correlation.json; all control params [VERIFY]).

A protection (RoCoF/LoM, U/O-freq, U/O-voltage, LFDD, thermal, recloser)

Seam (src/GridSim.Core/Assets/Protection/): a protection is a generalisation of the inverter trip block - one or more definite-time ProtectionStages (a MonitoredQuantity crossing a threshold, held for a dwell, driving a ProtectionAction). ProtectionEvaluator is the shared dwell/edge state machine; ProtectionController (an IActiveAssetDynamics, AssetKind.Protection) drives a set of stages. Actions map onto the existing channels: TripInfeed (remove infeed), ShedDemand (LFDD, a positive swing-power term), OpenBranch/RestoreBranch (a topology recloser).

Author, don't code. Add a kind: "protection" class to asset-classes.json with the canonical stage parameters (rocofTripHzPerS+rocofTripConfirmS, ufTripHz/ofTripHz, uvTripPu/ovTripPu, overloadTripFraction, lfddStageHz+lfddBlockMw, autoRecloseDelayS), and place it in a protections.json ({name, class, monitorBus, capacityMw, targetBranch?}). ProtectionController builds a stage per present key. A recloser (has autoRecloseDelayS) opens targetBranch and recloses after the delay. GDA NetworkModel/build_protections.py emits grounded classes.

Rules that are invariants, not style. Null-gated (GridModel.Protections is null unless a case ships protections.json) so the frequency AND power-flow reductions gates hold byte-identically. Grounded thresholds tag Recorded/DerivedFromRecorded (GDA rocof_events/lfdd_risk grades); every unmeasurable setting is CuratedPlaceholder+[VERIFY] (recloser/voltage/thermal - no operational data exists). Voltage and thermal protections are inert in the pure lumped model - they need the network context feed (AssetAwareFrequencyIntegrator.SetNetworkState from LiveSim's solve); a guard test codifies this. A topology recloser goes through Branch.InServiceYBus/BFS skip → IslandPartition (per-island slack election / blackout) → re-solve; a fully-connected model must solve bit-identically (pin it before exposing any topology feature).

Copy Assets/Protection/ProtectionController.cs, PowerFlow/IslandPartition.cs, and the tests ProtectionTests / IslandPartitionTests / ProtectionRecloserTests.

An estimation method

Seam: IWlsStep (src/GridSim.Core/Estimation/WlsStep.cs) - the one iteration of the normal-equation solve behind the estimator: Begin(model, measurementModel, w) binds buffers and sparsity once per bad-data pass; the step and observability-gate methods do the per-iteration work. WlsStateEstimator owns everything around it (iteration control, chi-square, bad-data removal, robust reweighting), so a new method is mostly a new step.

Wire it via the EstimationMethod enum and the --method mapping in Program.cs (wls, constrained, huber, lav today - unknown values fail loud). Note Huber/LAV are implemented as reweighting loops around the standard step in WlsStateEstimator, not separate steps: check whether your method is a new step (ConstrainedDenseWlsStep is the example) or a new weighting policy.

Copy DenseWlsStep for the shape and SparseWlsStep for the at-scale variant; keep both in agreement (dense<->sparse agree to ~1e-8 on case118 with identical verdicts, and bench --estimate measures both).

Prove it with the estimation validation ladder: crossval_estimation.py and se_residual_check.py (14-validation-tools.md) - the optimality check is method-agnostic evidence that your x^ is a real optimum.

A measurement kind

Seam: the MeasurementKind enum (Core/Estimation/Measurement.cs) plus its physics in Core/Estimation/MeasurementModel.cs - two switch sites: h(x) evaluation and the analytic Jacobian row (the existing kinds VmPu, VaDeg, PInjMw, QInjMvar, PFlowMw, QFlowMvar show both patterns, bus and branch).

Then mirror it everywhere the contract reaches: the export writer already carries kind by name, but the Python sidecars evaluate h independently - add the kind to tools/crossval/se_residual_check.py (h + Jacobian row) and, if pandapower can measure it, to crossval_estimation.py's add_measurements. Update EstimateExportContractTests so CI locks the extended contract. MeasurementSet.Validate gives structural checks (bus/branch resolution, sigma sanity) for free.

The VaDeg PMU kind is the worked example of the whole chain: a linear h, a one-entry Jacobian row, its own sigma source, and sidecar mirroring.

See also