Technical manual — chapter list

The evidence engine

The evidence engine turns recorded GB frequency events into defensible counterfactual claims, in four gated stages: a recorded event model, a replay validator that must reproduce the record with no device before anything else runs, a scenario/fleet layer that answers "what would a device have done", and an evidence report that assembles the dossier with the avoided-cost arithmetic exposed. The code lives in src/GridSim.Core/{Events,Replay,Scenarios,Fleet,Evidence}/; the CLI verbs are replay-event, scenario, fleet and evidence (user manual chapters 8 and 9).

The event model

Events/GridEvent.cs describes a real event from the record:

  • PowerStep(AtSeconds, DeltaPMw, What, Provenance) - one step in the loss sequence. Negative MW is a generation loss/deficit; positive is restoration. Every step carries a ProvenanceTag and a What string that cites its source (or carries [VERIFY] if unsourced - see chapter 8).
  • RecordedAction(AtSeconds, DeltaPMw, What, Provenance) - a protective or operator action the record shows, most importantly low-frequency demand disconnection (LFDD). LFDD enters the swing model as a positive deltaP: load removed = deficit reduced. Feeding the recorded action in is replay, not prediction - the record says what happened and the engine reproduces it.
  • ResponseService(Mw, DeadbandHz, FullDeliveryDeviationHz, TimeConstantS, What, Provenance) - a frequency-response service the system held at the event: volumes from the procurement record (EAC auction cleared quantities for the delivery block), delivery shape from the published service terms. Integrated by the swing model as an independent proportional responder (chapter 5, step 4). Kept in BOTH the baseline replay and every counterfactual: stripping it from a counterfactual would credit the device with response the system already owned.
  • RecordedOutcome - the headline metrics a replay must reproduce: InitialRocofHzPerS (magnitude compared, sign informational), NadirHz, optional SettleHz, LfddMwShed (total demand disconnected, 0 if none), an optional NesoTraceCsvPath (a 1-second NESO trace enabling the per-sample RMSE - data-gated, dormant until a file is dropped in), a provenance tag, notes, and RocofMeasurementWindowS - the measurement basis of the RoCoF figure. 0 (default) declares an instantaneous/instrument figure; a positive value declares a windowed average (e.g. 1.0 for a max 1-second delta derived from the public 1 Hz trace), and the validator scores the simulation on the same basis.
  • GridEvent - id, title, WhenUtc, the pre-event operating point (PreEventInertiaMwS, PreEventDemandMw), the losses, the recorded actions, the outcome, the held response services, and event-level provenance/notes. TotalLossMw is the sum of loss magnitudes. ResponseServicesComplete = true declares that the service list enumerates ALL frequency response the system held - correct for GB events after EAC go-live (November 2023), when the DC/DM/DR daily auctions became the sole response procurement route - and makes ApplyRecordedServices zero the generic ResponsiveFraction governor term, which would otherwise double-count the listed response. Load damping is physics, not procurement, and always stays. PreEventFrequencyHz carries the recorded frequency the trip landed on; it maps to DynamicsConfig.InitialFrequencyHz (chapter 5) so the replay starts in equilibrium at the recorded operating point - services respond to absolute frequency, so an offset start changes both the pre-deployed response and how deep into the delivery curves the first seconds run. GridEvent.ApplyRecordedServices(cfg) is the single Events -> Dynamics mapping point; the replay validator, scenario engine and evidence report all call it, so every path integrates the same recorded operating state.

Two projections of the event exist because two different questions are asked:

Projection Contents Used by
ToFrequencyEvents() losses plus recorded actions (LFDD as +deltaP), time-ordered the baseline replay - reproduce everything the record says happened
ExogenousLossEvents() the losses only every counterfactual - the device is meant to replace the recorded LFDD, which is an endogenous consequence, not a cause; feeding it back in would double-count

Events/EventCatalog.cs loads/saves catalogues (schema gridsim-event-catalog/1, under data/events/); every event timestamp passes ForwardInferenceGuard.RejectFuture on load, so a catalogue can never smuggle a future "event" into the pipeline. Three built-ins ship: gb-2019-08-09 (the Hornsea + Little Barford disturbance, loss sequence and outcome sourced paragraph-by-paragraph from the Ofgem investigation report; the measured operating point - NESO's 215 GVA.s outturn inertia and the WLS estimator's 21.1 GW transmission-net demand - additionally ships as data/events/gb-2019-08-09-estimated.json, built by GDA NetworkModel/build_event_catalog.py), gb-2023-12-22 (IFA + Cottam, measured end to end: settlement-metered losses, measured operating point including the recorded pre-event frequency, EAC response services, BOALF BM recovery - see the user manual chapter 8 for the full sourcing and its honest Marginal verdict), and gb-largest-loss-1320 (a screening case whose figures remain CuratedPlaceholder and flagged [VERIFY] until sourced).

Replay validation: ReplayValidator

Replay/ReplayValidator.cs:ReplayValidator.Validate is the credibility gate: run FrequencyDynamics.Simulate on the event's recorded pre-event inertia/demand and losses+actions with no device, and check the simulated RoCoF and nadir against the RecordedOutcome. Only a Reproduced baseline should unlock a counterfactual - reproduce reality first.

Setup. The config is defensively stripped of device terms (DeviceRotationalInertiaMwS = 0, FastResponseMw = 0), the event's recorded response services are stamped in via ApplyRecordedServices (they are system facts, not device terms), and the simulation window is extended to cover the whole recorded sequence:

DurationSeconds = max(DurationSeconds, lastEventAtSeconds + 30)

Real events play out over minutes - on 9 Aug 2019 the GT trips land at ~58/90 s and LFDD at ~76 s - so a sim clipped at the 30 s default would replay a different event than the record describes.

Errors. RoCoF error is relative on magnitudes: ||sim| - |rec|| / |rec| (or |sim| outright when the recorded RoCoF is ~0); nadir error is absolute Hz.

Measurement basis. The simulated RoCoF entering that comparison depends on the outcome's declared RocofMeasurementWindowS. 0: the sim's 20 ms maximum (instantaneous basis). Positive w: the sim is sampled on a w-second grid - the same grid a real logger produced the recorded figure on - and the statistic is the median over the grid's sub-second phase of the per-phase maximum consecutive-sample slope. A continuous sliding-window maximum is NOT used: it always finds the worst-aligned window, a systematically steeper read than any sampled trace can produce - on gb-2023-12-22 that bias alone was worth roughly 17 percentage points of RoCoF error.

Declared-uncertainty envelope. The validator additionally replays the same no-device baseline at the scenario engine's Best and Worst corners (ScenarioCorners.Apply - the documented error bars on the unfitted arrest constants: load damping, responsive fraction, governor lag) and reports the resulting nadir bracket plus whether the recorded nadir lies inside it (ReplayResult.NadirBestCornerHz / NadirWorstCornerHz / RecordedNadirInEnvelope). This is reporting, never gating: the verdict is computed from the nominal run only. A record inside the bracket is consistent with the model within its own error bars; a record outside it points at a quantity the corners deliberately do not scale - inertia - which is how gb-2023-12-22 isolates the outturn feed's missing embedded contribution.

Verdict ladder (ReplayTolerance, defaults RocofRelTol = 0.15, NadirAbsToleranceHz = 0.15, RmseWarnHz = 0.05):

Verdict Condition
Reproduced RoCoF error <= 15 % and nadir error <= 0.15 Hz
Marginal both within 2x tolerance (<= 30 % / <= 0.30 Hz)
Failed otherwise

A Reproduced verdict is additionally demoted to Marginal when a recorded trace is attached and the per-sample RMSE exceeds RmseWarnHz - headline metrics matching while the trace shape diverges is not full reproduction.

Inertia cross-check. The validator also inverts the recorded RoCoF through InertiaEstimator.EstimateInertiaMwS and reports the implied inertia (InferredInertiaMwS) beside the event's stated PreEventInertiaMwS. The inversion uses only the losses inside a 5-second RoCoF window (AtSeconds <= 5, falling back to TotalLossMw if that window is empty): the initial slope is set by the near-instant losses plus fast loss-of-mains cascades, not by a staged sequence's two-minute total - the 58 s and 90 s GT trips must not count against the initial RoCoF.

Per-sample RMSE (data-gated). When Outcome.NesoTraceCsvPath names an existing file, the recorded trace is read through NesoFrequencyCsv (which enforces the horizon guard per row), upsampled by RocofUpsampler to the simulation timestep, and aligned so the sample nearest the event's WhenUtc lines up with the sim's first loss instant; the RMSE is computed over the simulated points from that instant on. With no trace the field is null and the summary reads "RMSE n/a (no trace)".

The result (ReplayResult) carries both sides of every comparison, the inferred inertia, the RMSE, a FrequencyLimits.Assess score of the simulated response, a one-line summary, and the full simulated trajectory.

Counterfactual scenarios: ScenarioEngine

Scenarios/ScenarioEngine.cs:ScenarioEngine.Run executes the counterfactual sweep inertia x devices x corners, but only after the gate:

ReplayResult baseline = ReplayValidator.Validate(spec.Event, spec.BaseConfig, spec.Tolerance);
if (baseline.Verdict != ReplayVerdict.Reproduced)
    throw new BaselineNotReproducedException(baseline);

BaselineNotReproducedException (Scenarios/ScenarioSpec.cs) carries the failed baseline and refuses in plain language: "Reproduce reality first." There is no override flag.

Each cell replays the event's exogenous losses (never the recorded LFDD) at a swept system inertia with the device applied, and scores the result with FrequencyLimits.Assess. The axes:

  • Inertia - SweepAxes.GbInertiaTrajectoryGvaS = {260, 155, 120, 102, 50} GVA.s by default (the GB trajectory from the TSGB thesis; the constants are marked [VERIFY] in source).
  • Devices - DeviceConfig(Name, RotationalInertiaMwS, FastResponseMw, ...). DeviceConfig.Apply is the single intrinsic-vs-synthetic chokepoint: real (1/2)J*omega^2 energy goes to DynamicsConfig.DeviceRotationalInertiaMwS, fast response to FastResponseMw - two separate terms, never merged.
  • Corners - ScenarioCorners.Apply scales the documented-but-unfitted constants to carry the model's own error bars into every result:
Corner Damping Responsive fraction Governor tau
Best x1.5 x1.5 (capped at 1.0) x0.7
Nominal - - -
Worst x0.6 x0.6 x1.4

Corners do not touch the initial RoCoF - that is set purely by E at t = 0, before governor or damping act - so the RoCoF story is corner-independent.

The result (ScenarioMatrix) contains every ScenarioCell plus an InertiaRiskCurve: at each inertia level, the nominal-corner outcome with and without the device (the first device on the axis with any inertia or FFR), including the LfddAvoidedByDevice flag - LFDD at risk without the device (nadir <= 48.8 Hz) and not with it.

Fleet dynamics: why FFR is not additive

Fleet/FleetDynamics.cs:FleetDynamics.Simulate is the fleet analogue of FrequencyDynamics.Simulate: identical governor/damping/swing physics (config device terms are zeroed - the fleet is the device), but the device term is the sum of N independently deadbanded, lagged and onset-staggered unit outputs (FleetUnit).

The two budgets behave differently by mechanism, not by assumption:

  • Intrinsic inertia is N-additive. Stored energy just sums: E = max(1, baseInertiaMwS + sum unit inertia).
  • Fast response is not. Each unit arms the first time system frequency leaves its own deadband, and its output then lags that crossing by its StaggerSeconds (a response latency - detection/dispatch/comms - measured from the deadband crossing, not from sim t = 0; anchoring to t = 0 made stagger a no-op whenever units armed before the loss hit). Identical units that stay engaged all reach full output during a sustained excursion, so the peak fleet FFR is additive - but a staggered fleet has fewer units online at the early nadir, which is when the fall must be arrested.

FleetResult therefore reports both PeakFleetFfrMw and - the decision-relevant figure - FfrAtNadirMw, the fast response actually online at the nadir instant. The gap between them is the emergent non-additivity, and it shows up as a deeper nadir than an aggregate device of the same total budget. With a single unit at zero stagger the solve is byte-identical to FrequencyDynamics.Simulate with that device: the fleet generalises the physics, it does not fork it.

Fleet/FleetSpec.cs:FleetSpec.Uniform(n, totalInertia, totalFfr, ..., maxStaggerS) builds n identical units splitting the budgets, onset-staggered evenly across [0, maxStaggerS].

The evidence dossier: EvidenceReport

Evidence/EvidenceReport.cs:EvidenceReport.Build assembles the umbrella dossier (schema gridsim-evidence/1, serialised as { schema, report }). It runs the gate itself (ReplayValidator.Validate), sets CounterfactualUnlocked = (verdict == Reproduced), and withholds the counterfactual, risk-curve, fleet and avoided-cost sections when the gate fails - the summary then says so rather than presenting an ungrounded claim.

Columns (EvidenceColumn(Label, Provenance, RocofHzPerS, NadirHz, SettleHz, LfddAtRisk, DeviceRotationalInertiaGvaS, DeviceFfrGw)). The device's rotational inertia and fast response are separate labelled fields in every column - intrinsic (1/2)J*omega^2 is never blurred with the FFR injection:

Column Provenance Present
recorded the event outcome's own tag always
simulated (no device) DerivedFromRecorded always
counterfactual (device @ N GVA.s) Counterfactual only when unlocked

The counterfactual column replays ExogenousLossEvents() with the device at CounterfactualInertiaGvaS (default: the event's own recorded pre-event inertia). The risk curve reuses ScenarioEngine.Run (which re-checks the gate). The fleet section compares a single aggregate device against FleetUnits staggered units of the same total budget - defaults 100 units over a 10 s stagger window, because GB primary response is fully delivered by 10 s and a diverse FFR/DSR fleet staggers across that window; a sub-second default would finish before the nadir and hide the non-additivity the section exists to show. Both fleet runs pass the caller's base config (the device is modelled through the FleetSpec; passing the device config would count it twice).

Avoided cost (Evidence/AvoidedCost.cs) is computed only when both hold: the counterfactual device keeps the nadir above the LFDD threshold, and the record actually shed load (Outcome.LfddMwShed > 0) - otherwise the shed would still have happened, or there was nothing to avoid. The arithmetic is exposed in full in AvoidedCostResult:

AvoidedCostGbp = max(0, LfddMwShed) x VollPerMwh x LfddDurationHours

The MW shed is the recorded figure, not a marketing number. The Value of Lost Load and the duration are policy inputs, not physics: AvoidedCostInputs defaults to £6,000/MWh over 0.5 h with provenance CuratedPlaceholder (GB VoLL estimates span roughly £6k-£17k/MWh), and the provenance is printed beside the figure wherever it is quoted - replace it with a cited number before quoting the result (chapter 8).

The dossier summary names the verdict, whether the device keeps the nadir above LFDD, and either the avoided cost with its provenance or "no avoided cost claimed"; a locked dossier's summary states that the counterfactual is withheld.

See also