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 aProvenanceTagand aWhatstring 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, optionalSettleHz,LfddMwShed(total demand disconnected, 0 if none), an optionalNesoTraceCsvPath(a 1-second NESO trace enabling the per-sample RMSE - data-gated, dormant until a file is dropped in), a provenance tag, notes, andRocofMeasurementWindowS- the measurement basis of the RoCoF figure.0(default) declares an instantaneous/instrument figure; a positive value declares a windowed average (e.g.1.0for 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.TotalLossMwis the sum of loss magnitudes.ResponseServicesComplete = truedeclares 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 makesApplyRecordedServiceszero the genericResponsiveFractiongovernor term, which would otherwise double-count the listed response. Load damping is physics, not procurement, and always stays.PreEventFrequencyHzcarries the recorded frequency the trip landed on; it maps toDynamicsConfig.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.Applyis the single intrinsic-vs-synthetic chokepoint: real (1/2)J*omega^2 energy goes toDynamicsConfig.DeviceRotationalInertiaMwS, fast response toFastResponseMw- two separate terms, never merged. - Corners -
ScenarioCorners.Applyscales 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
- Frequency dynamics - the swing model,
DynamicsConfig,InertiaEstimator,FrequencyLimits - State estimation - where the measured operating points in the event catalogue come from
- Provenance and invariants -
ProvenanceTag,[VERIFY], the horizon guard on catalogue load - File and wire formats -
gridsim-event-catalog/1,gridsim-evidence/1 - User manual: Replaying recorded events, Counterfactuals