Frequency dynamics
GridSim models system frequency with a centre-of-inertia (COI) swing equation: after a power imbalance deltaP, frequency moves at
df/dt = f0 / (2E) * deltaP E = sum H*MBase (spinning kinetic energy, MW.s)
The initial rate of change of frequency (RoCoF) is set entirely by the inertia
E and the size of the loss; governor droop and load damping then arrest the
fall, producing a nadir and a settling point. This chapter documents the model
exactly as implemented: the batch simulator, its configuration, the stateful
integrator, the pluggable inertia-model seam and the distributed multi-domain
model behind it, the swing-equation inversion used for inertia monitoring, the
GB frequency limits, and the NESO trace path.
All of it lives under src/GridSim.Core/Dynamics/ and
src/GridSim.Core/Frequency/.
The batch simulator: FrequencyDynamics.Simulate
Dynamics/FrequencyDynamics.cs:FrequencyDynamics.Simulate takes a system
inertia (MW.s), a base demand (MW), a set of FrequencyEvent(AtSeconds, DeltaPMw) power steps (negative MW = a generation loss) and an optional
DynamicsConfig, and returns a FrequencyResponse. The integration is
explicit forward Euler on a fixed timestep (StepSeconds, default 20 ms -
deliberately the same cadence the NESO upsampler produces, so simulated and
recorded traces share a timeline). Per step, at time t:
- Event forcing - every event with
AtSeconds <= tcontributes its deltaP (steps are cumulative and permanent; there is no event decay). - Governor droop - a first-order lag toward the droop target
-(deltaf/f0)/DroopPu * Presp, wherePresp = ResponsiveFraction * baseDemandMwis the droop-responsive capacity. The lag time constant isGovernorTimeConstantS. - Device fast response (FFR) - zero inside
FastResponseDeadbandHz; outside it, a proportional target-(FastResponseMw/0.5)*deltafclamped to +/-FastResponseMw(full output at 0.5 Hz deviation), lagged with time constantmax(dt, FastResponseTimeConstantS). - Recorded system response services - one independent first-order state
per
SystemResponseService(Mw, DeadbandHz, FullDeliveryDeviationHz, TimeConstantS): the target is a droop line reaching full output at the service's own delivery deviation (DC 0.5 Hz, DM/DR 0.2 Hz per the published service terms), zero inside the deadband, capped at +/-Mw, computed by the generalSwingKernel.FastResponseTargetoverload. These model the response the REAL system held (volumes from the EAC auction record), not a device - the replay keeps them where it strips device terms, and counterfactuals keep them too. A single droop line omits DC's low-gain (5 %) band between 0.015 and 0.2 Hz, which is acceptable for excursions well past 0.2 Hz where delivery sits on the steep segment. - Load damping -
-d*deltafwithd = DampingPctPerHz/100 * baseDemandMw(MW per Hz). - Swing update - `rocof = f0/(2E) * (pEvent + pGov + pTsgb + pServices
- d*deltaf)
, thenf += rocof * dt`.
- d*deltaf)
The inertia is E = max(1, systemInertiaMwS + DeviceRotationalInertiaMwS):
the device's intrinsic stored energy (real (1/2)J*omega^2) is summed into E and cuts
RoCoF directly, while its fast response is a separate power injection that
arrests the nadir. The two device terms are never merged - the deprecated
SyntheticInertiaMwS alias exists only to forward to
DeviceRotationalInertiaMwS and is marked [Obsolete].
The returned FrequencyResponse carries:
| Field | Meaning |
|---|---|
InitialRocofHzPerS |
the largest-magnitude per-step RoCoF seen (the post-event spike) |
NadirHz, NadirSeconds |
the lowest frequency and when it occurred |
SettleHz |
the frequency after the final update |
Points |
the full 20 ms trajectory as FrequencyPoint(Time, Hz, RocofHzPerS) |
Trajectory timestamps are synthetic, anchored at 2000-01-01T00:00Z - the simulator never reads the clock.
DynamicsConfig fields and defaults
Dynamics/FrequencyDynamics.cs:DynamicsConfig is an immutable record. Its
calibration status is part of the contract and is documented on the type
itself:
| Field | Default | Status |
|---|---|---|
NominalHz |
50.0 | fixed |
DroopPu |
0.05 | 5 % governor droop - GB Grid Code, documented, not fitted |
DampingPctPerHz |
1.0 | load self-damping, % of demand per Hz - literature-typical, unfitted |
ResponsiveFraction |
0.20 | share of demand carrying droop headroom - literature-typical, unfitted |
GovernorTimeConstantS |
8.0 | primary-response lag (s) - literature-typical, unfitted |
StepSeconds |
0.02 | integration step; matches the NESO upsampler |
DurationSeconds |
30.0 | simulation window (the replay validator extends it, see chapter 7) |
DeviceRotationalInertiaMwS |
0.0 | device intrinsic (1/2)J*omega^2 stored energy, summed into E |
FastResponseMw |
0.0 | device FFR cap (MW) |
FastResponseTimeConstantS |
0.5 | FFR ramp lag (s) |
FastResponseDeadbandHz |
0.015 | FFR engagement deadband (Hz) |
InertiaModel |
LumpedScalar |
which inertia model to run (see below) |
Distributed |
null |
DistributedConfig when the distributed model is selected |
SystemResponseServices |
[] |
recorded DC/DM/DR the real system held (see step 4 above); populated from the event record by GridEvent.ApplyRecordedServices |
InitialFrequencyHz |
null |
recorded pre-event frequency. When set, the sim starts in equilibrium there: governor + service lag states pre-loaded to their static targets at the offset, and a constant standing-imbalance term balances them so df/dt(0) = 0 (the deficit the BM was carrying). Device terms start undeployed. Null = nominal start, all states zero |
The unfitted constants (damping, responsive fraction, governor lag) set the
nadir and recovery shape but not the initial RoCoF, which depends only on E
and deltaP. Their uncertainty is roughly a factor of two; the scenario engine's
best/worst corners bracket exactly these three. The bundled NESO sample
(data/neso/frequency-sample.csv) is ambient data (+/-0.1 Hz band) - it pins
the RoCoF scale but cannot calibrate the nadir constants; treat simulated
nadir/settle values accordingly until a real loss-of-infeed trace is attached.
The stateful integrator: FrequencyIntegrator
Dynamics/FrequencyIntegrator.cs:FrequencyIntegrator is the running-clock
twin of Simulate for the live dashboard: identical per-step physics, but
advanced one Step(dtSeconds, eventMw) call at a time with governor, damping
and FFR state carried between calls. An arbitrary deltat (1 ms to 20 minutes) is
split into internal sub-steps of at most 20 ms (MaxInternalDt), bounded at
4,000 sub-steps per call, so explicit Euler stays stable - a huge step
settles instead of blowing up.
The shared per-step terms - governor target and FFR target - are factored
into Dynamics/SwingKernel.cs:SwingKernel (GovernorTarget,
FastResponseTarget) so FrequencyDynamics, FrequencyIntegrator and the
fleet kernel (FleetDynamics, chapter 7) cannot drift apart.
The inertia-model seam: IInertiaModel
Abstractions/IInertiaModel.cs:IInertiaModel is the swappable
frequency/inertia model seam, mirroring IPowerFlowSolver:
Simulate(model, baseDemandMw, events, config)- returns the COI-collapsedFrequencyResponse, so every scalar consumer works with either model;CoiInertiaGvaS(model)- the aggregate every scalar readout expects;LastDistributedResult- the spatial detail of the most recent solve, ornullfor the lumped model.
Dynamics/InertiaModelSelector.cs:InertiaModelSelector.Create resolves the
InertiaModel enum to an implementation. Unlike the power-flow selector there
is no shape-based auto-selection: the inertia model is an explicit user
choice, defaulting to InertiaModel.LumpedScalar.
Dynamics/LumpedScalarInertiaModel.cs:LumpedScalarInertiaModel is a thin
adapter: FrequencyDynamics.Simulate(model.SystemInertiaGvaS * 1000, ...) -
selecting it is exactly the classic behaviour.
The distributed multi-domain model
Dynamics/Distributed/ holds the experimental spatially-resolved model. It
is additive and opt-in, and its outputs carry
ProvenanceTag.UnvalidatedAtScale semantics: self-consistent, passing the
reduction gates, but with no GB-scale field validation (that awaits PMU/DFR
data). See chapter 8 for the tag.
Domain assignment
Distributed/DomainAssignment.cs:DomainAssignment.Assign partitions the
model's generators into speed domains - groups sharing a mechanical
speed/pole ratio, treated as one coherent inertial body:
Distributed/PoleMap.cs:PoleMap.ForTypemaps the generator type string to (poles, rpm, kind): turbo-alternators (nuclear/coal/biomass/ccgt/ocgt/chp/ gas) are 2-pole 3,000 rpm synchronous; recips 4-pole; hydro/pumped 8-pole 750 rpm; storage/battery/BESS becomeSyntheticInertia(virtual) domains; everything else inverter-based isPll. This mapping is a documented modelling assumption, not per-unit data - GDA holds no pole/speed field.- A machine with
H > 0but an unknown type defaults to a 2-pole synchronous unit (the WSCC case9 generators land here). - Machines are grouped by
(poles, kind); each domain aggregatesE_d = sum H*MBase(MW.s), rated MVA, andGVAd = sum MBase*Cwhere the availability/coupling coefficientC = clamp(|Pg|/MBase, 0, 1)is a dispatch-derived proxy. Distributed/SpeedDomain.cs:SpeedDomain.IsSwingMass- a domain participates in the electromechanical swing only if it isSynchronouswithE > 0; virtual domains contribute to dominance accounting only.
The dominance read-out (Distributed/DominanceReport.cs:DominanceReport)
orders domains by GVAd and computes deltaGVa = (GVAd1 - GVAd2)/GVAd2; a
dominant domain exists when deltaGVa >= DominanceThreshold (default 0.10).
The 0.10 default is an engineering-judgement value, not a theory-derived or
fitted constant - but it is empirically anchored: over 103,433 GB samples
(GDA Derived/speed_domains, 2014-2026) the dominance gap has P10 ~ 0.106, so
0.10 flags almost exactly the bottom decile of conditions (fires ~9.4% of the
time; 0.08 -> 7.5%, 0.12 -> 11.4%). It is init-settable for calibration.
The multi-domain swing and its invariants
Distributed/MultiDomainSwing.cs:MultiDomainSwing.Simulate integrates one
swing equation per swing-mass domain, coupled by tie synchronising power
K_ij*(delta_i - delta_j). The construction guarantees two reduction properties:
- T0 (exact reduction). With <= 1 swing mass - including
DistributedConfig.SingleDomain = true-DistributedInertiaModeldelegates to the lumpedFrequencyDynamics, so the degenerate case is bitwise identical to the lumped model, not merely close. - T1 (COI preservation). The coupling matrix is symmetric, so tie power is antisymmetric and sums to zero across domains: it moves energy between domains without moving the centre of inertia. The system-level forcing (event, governor, damping, FFR) is computed from the inertia-weighted COI frequency exactly as the lumped model does, then distributed across domains in proportion to inertia - so the per-domain updates re-aggregate to the lumped COI trajectory (to rounding).
The localised loss lands on the domain hosting the largest unit by rated MVA
(its credible-loss home). The device's rotational inertia is folded into the
fleet proportionally so the COI total still equals the lumped
E + DeviceRotationalInertiaMwS. Integration is semi-implicit (symplectic)
Euler on the same 20 ms step - frequency first, then angle with the updated
frequency - which stays energy-bounded under the stiff Kron-derived coupling
where plain forward Euler would grow without bound.
Coupling comes from Distributed/DomainCoupling.cs:DomainCoupling.SynchronisingMatrix:
the bus admittance matrix is Kron-reduced onto the generator buses and the
cross-domain synchronising susceptance read off as
K = BaseMva * Im(Y_reduced) at flat 1 pu voltage (anti-synchronising
artefacts <= 0 are ignored). Models with no branch reactance fall back to a
uniform stiffness targeting DistributedConfig.TargetInterAreaHz (default
0.5 Hz, the GB inter-area mode band).
Propagation delays
Distributed/PropagationDelay.cs:PropagationDelay.Compute supplies the
inter-domain delay matrix tau. It is off by default
(DistributedConfig.EnablePropagation = false) because the retardation
perturbs the centre of inertia (real physics), so the machine-eps COI invariant
only holds with it off. When enabled:
- domain centroids are computed from the members'
Bus.X/Ymap coordinates, scaled by the GB bounding-box spans (660 km x 1,020 km); - the wave speed is derived from inertia density (
v ~ sqrt(rho_ref/rho), reference 200 MW.s/km -> 1,000 km/s) and clamped to the published electromechanical-wave band 500-1,500 km/s (MinWaveSpeedKmPerS/MaxWaveSpeedKmPerS) - the clamp is the honest statement: only the band is asserted, not the absolute GB value; tau_ij = distance/v, unlessDistributedConfig.ProvidedTauSecondssupplies an externally-derived tau for the domain pair's representative buses (GDA'spropagation_delayslake product, fetched over the bridge byNet/GridBridgeClient.cs:GridBridgeClient.QueryPropagation).
In the swing loop each tie reads the far domain's angle retarded by tau via a per-step angle history; tau = 0 reduces exactly to the undelayed model. A negative/NaN/infinite tau collapses to zero delay and any finite tau is clamped to the run length, so a bad value cannot index outside the history.
Distributed outputs
Distributed/DistributedResult.cs:DistributedResult rides on
IInertiaModel.LastDistributedResult: per-domain DomainTraces (same shape
as FrequencyResponse, one per coherent mass), the DominanceReport, a
per-bus event-RoCoF map, and - when propagation is on - a
PropagationReport (wave speed used, largest delay, per-domain arrival times,
where arrival = the first departure of more than 1 mHz from nominal).
The authored per-asset fleet: pAssetFleet
The swing loop is additive in power, so the inverter fleet enters as one more term.
Dynamics/AssetFrequencyDynamics.Simulate is a faithful copy of FrequencyDynamics.Simulate
that appends, last in the imbalance sum, the net power of the authored asset fleet and the
cumulative Loss-of-Mains trip deficit:
pImbalance = pEvent + pStanding + pGov + pTsgb + pServices - d*df + pAssetFleet - assetTripDeficit
Assets/AssetDynamicsFleet advances each bound controller (Assets/GridFollowingInverter,
GridFormingInverter) against its local frequency (an IGridFrequencySampler - the COI for
the lumped model). A predictor-corrector samples this step's predicted RoCoF, so a grid-forming
unit's synthetic-inertia term (p = -synInertia*rocof) lowers the initial RoCoF - as a power
injection, never merged into E. A grid-following unit trips on local RoCoF/vector-shift/LVRT; its
lost dispatched Pg becomes deficit exactly as RocofScreening treats a lost unit.
Gated by DynamicsConfig.EnableAssetDynamics (default off). With an empty/passive fleet both added
terms are 0.0, so x + 0.0 - 0.0 == x makes the trajectory bit-identical to the baseline -
the reduction gate (AssetDynamicsReductionTests). The live twin is
AssetAwareFrequencyIntegrator. Because every inverter control parameter is unmeasurable
([VERIFY]/CuratedPlaceholder), any real-fleet claim uses a sweep envelope (AssetEnvelope)
tagged UnvalidatedAtScale, validated against a recorded trace (AssetValidation) - not a point
curve. See docs/manual/18-extending-gridsim.md for authoring classes.
Inverting the swing equation: InertiaEstimator
Dynamics/InertiaEstimator.cs:InertiaEstimator recovers inertia from an
observed excursion - what NESO does after every large loss:
E = f0 * |deltaP| / (2 * |RoCoF|)
EstimateInertiaMwS(deltaPMw, rocofHzPerS, nominalHz = 50)- signs are ignored; returns+infwhen |RoCoF| < 1e-9 (no observable slope);EstimateInertiaGvaS- the same divided by 1,000, the GB reporting unit;EstimateHSeconds(..., baseMva)- the equivalent H = E/S on a given base;PredictRocof(inertiaMwS, deltaPMw)- the forward check, with E floored at 1 MW.s.
The estimate is clean because the very first RoCoF sample precedes any governor or fast-response action: at t = 0 the only imbalance is deltaP itself. The replay validator uses this inversion as its inertia cross-check (chapter 7).
GB limits and assessment: FrequencyLimits
Dynamics/FrequencyLimits.cs:FrequencyLimits holds the operating thresholds
and two classifiers.
| Constant | Value | Meaning |
|---|---|---|
OperationalLowHz / OperationalHighHz |
49.8 / 50.2 | NESO's operational band |
StatutoryLowHz / StatutoryHighHz |
49.5 / 50.5 | statutory +/-0.5 Hz |
LfddFirstStageHz |
48.8 | low-frequency demand disconnection, first stage |
RocofLimitLegacyHzPerS |
0.125 | pre-ALoMCP loss-of-mains relay setting |
RocofLimitModernHzPerS |
1.0 | GB relays as retuned by ~2022 |
RocofLimitHzPerS |
0.125 | back-compatible alias - pinned to legacy |
ActiveRocofLimitHzPerS |
1.0 (settable static) | the limit the "active" flag scores against |
RoCoF assessment is dual-threshold: every assessment reports the legacy and
modern breaches independently, plus an "active" flag scored against
ActiveRocofLimitHzPerS (settable, defaults to modern). The retained
RocofExceedsLimit/RocofLimitExceeded fields keep legacy (0.125 Hz/s)
semantics for back-compatibility. BreachesRocof compares with a 1e-9 Hz/s
tolerance.
Assess(FrequencyResponse)->FrequencyAssessment: the headline RoCoF/ nadir/settle, the limit flags (BelowOperational,BelowStatutory,LfddAtRiskat nadir <= 48.8 Hz) and a one-line summary chosen from a severity ladder (LFDD > statutory > active RoCoF > legacy RoCoF > operational > within limits).AssessTrace(IReadOnlyList<FrequencySample>)->FrequencyTraceReport: scans a measured trace - extremes, sample counts below the operational and statutory floors, whether 48.8 Hz was reached, and the worst sample-to-sample RoCoF|deltaf/deltat|against the same three thresholds.
The NESO trace path and RocofUpsampler
The measured-frequency types live in Frequency/FrequencyTypes.cs:
FrequencySample(Time, Hz) is one recorded reading;
FrequencyPoint(Time, Hz, RocofHzPerS) is a point on the fine timeline.
Frequency/NesoFrequencyCsv.cs:NesoFrequencyCsv.Read streams a NESO
system-frequency CSV (dtm,f) row by row, so a month of 1-second data
(~2.7 M rows) reads without loading into memory. Unparseable lines are
skipped. Two details are load-bearing:
- Timestamps land in UTC. Offset-carrying stamps are converted to UTC and
naive stamps are assumed UTC (
AssumeUniversal | AdjustToUniversal). Without this, the replay validator's RMSE alignment - which compares recorded times to the UTC event instant by raw ticks - drifts by the local offset during BST, enough to demote aReproducedbaseline toMarginal. - Every row passes
ForwardInferenceGuard.RejectFuture- the reader is structurally incapable of ingesting a recent or future sample (chapter 8).
Frequency/RocofUpsampler.cs:RocofUpsampler refines the coarse trace:
Upsample(samples, subSamplesPerInterval = 50)- between consecutive samples it computes the interval RoCoF(f2-f1)/deltatand fills the gap by linear interpolation,f(t) = f1 + RoCoF*t, emitting oneFrequencyPointper sub-sample (50 per 1-second interval -> 20 ms cadence, the resolution the dynamics model and an FFR controller want). A closing point repeats the final sample with RoCoF 0.Rocof(samples)- the per-interval RoCoF series, stamped at the later sample.Summarize(samples)->FrequencyStats(count, min/mean/max, worst |RoCoF| and when).
Consumers of this path: the CLI freq verb (summary + limits scan + an
upsample demonstration), the CLI replay verb (upsampled 20 ms playback via
Frequency/ReplayController.cs), and ReplayValidator's data-gated
per-sample RMSE, which upsamples the recorded trace to the simulation
timestep before comparing (see chapter 7).
See also
- Power flow - the steady-state solver whose model feeds
SystemInertiaGvaS - The evidence engine - replay validation, scenarios and fleets built on
Simulate - Provenance and invariants -
UnvalidatedAtScale, the horizon guard on the NESO reader - GDA integration - the bridge, including the
propagationquery - User manual: Frequency studies, Replaying recorded events