GB grid balancing — residual-demand volatility driving battery dispatch under two policies

Methodology card. This is the primary human- and agent-legible description of the model. The runnable stub beside it (stub.go) is the type-checked generative demonstration; this card carries the structure, assumptions, and validity regime that the Go code does not spell out.

System

The GB electricity system’s short-run supply–demand balance, as seen by a grid-scale battery energy storage system (BESS). Residual demand — national load net of embedded wind and solar — is the quantity dispatchable plant and storage must meet. It mean-reverts toward a conditional mean with Gaussian shocks. Two structural signals respond to it: an imbalance price (linear in net load) and a carbon intensity (also linear, since dirtier marginal plant runs at higher net load). Two identical batteries then arbitrage the same market under two different threshold policies — one price-driven, one carbon-driven — so their cycling, revenue and carbon savings can be compared under identical conditions. The quantities of interest are battery cycling (equivalent full cycles), arbitrage revenue, and carbon saved, and how they respond as renewable penetration — and hence residual-demand volatility — rises.

The generative core is fifteen partitions: five shared signals and two symmetric five-partition policy chains.

Shared signals

Partition Iteration State Role
residual_demand continuous.OrnsteinUhlenbeckExactGaussianIteration [residual_mw] Mean-reverting net-load process (data-free stand-in for NESO replay)
price_noise continuous.OrnsteinUhlenbeckExactGaussianIteration [noise_gbp] OU intra-period price noise (μ=0)
carbon_noise continuous.OrnsteinUhlenbeckExactGaussianIteration [noise_gco2] OU carbon-intensity noise (μ=0)
price ImbalancePriceIteration [price_gbp_per_mwh] slope·residual + intercept + noise
carbon_intensity CarbonIntensityIteration [carbon_gco2_kwh] slope·residual + intercept + noise (co-moves with price)

Policy chain (instantiated twice, with prefix price_ and carbon_)

Partition Iteration State Role
<p>_dispatch PriceThresholdDispatchIteration / CarbonThresholdDispatchIteration [dispatch_mw] Charge / discharge / hold at full power on the signal’s thresholds
<p>_battery BatteryIteration [soc_mwh, actual_dispatch_mw] SoC tracker with efficiency + SoC-limit back-calculation
<p>_efc BatteryDegradationIteration [cumulative_efc] Running equivalent-full-cycle accumulator
<p>_revenue RevenueIteration [cumulative_revenue_gbp] Running revenue against the price signal
<p>_co2_saved CarbonSavingsIteration [cumulative_tco2] Running carbon displaced against the carbon signal

Residual demand. An OU process is the natural choice: residual demand mean-reverts toward its mean with a ~1.4 h half-life. Renewable penetration enters as the OU volatility σ (baseline + penetration·per_penetration): more wind/solar → larger net-load swings. This is the data-free replacement for replaying a NESO CSV — the same OU process the downstream repo infers from data.

Imbalance price and carbon intensity. Both are structural linear responses to residual demand plus mean-reverting noise. Coefficients are set so the baseline price (~£35/MWh) sits between the charge (£25) / discharge (£45) thresholds and the baseline carbon intensity (~175 gCO₂/kWh) sits between its charge (100) / discharge (250) thresholds, so each battery earns only when volatility carries its signal across a threshold. Driving both signals off the same residual-demand process reproduces their real co-movement — a low-wind period simultaneously raises the price and the carbon intensity.

Battery. Dispatch is clipped to the power rating; SoC updates through one-way charge/discharge efficiencies and is clamped to [min_soc, max_soc] with the actual dispatch back-calculated when a limit binds. EFC, revenue and carbon saved accumulate from the actual (post-constraint) dispatch. The two batteries are physically identical; only their dispatch policy differs.

Partition wiring

The partition dependency graph, derived statically from the stub’s BuildStub wiring by pkg/graph. Solid arrows are within-step params_from_upstream wiring (which imposes a computation order); dashed arrows leaving a shaded past-copy node are lag reads of a partition’s committed state from an earlier step — drawn as separate source nodes so the graph stays a DAG.

flowchart TB
  n0["residual_demand"]
  n1["price_noise"]
  n2["carbon_noise"]
  n3["price"]
  n4["carbon_intensity"]
  n5["price_dispatch"]
  n6["price_battery"]
  n7["price_efc"]
  n8["price_revenue"]
  n9["price_co2_saved"]
  n10["carbon_dispatch"]
  n11["carbon_battery"]
  n12["carbon_efc"]
  n13["carbon_revenue"]
  n14["carbon_co2_saved"]
  n0past["residual_demand"]
  n1past["price_noise"]
  n2past["carbon_noise"]
  n3past["price"]
  n4past["carbon_intensity"]
  n6past["price_battery"]
  n11past["carbon_battery"]
  n0past -.->|demand_partition| n3
  n1past -.->|noise_partition| n3
  n0past -.->|demand_partition| n4
  n2past -.->|noise_partition| n4
  n3past -.->|price_partition| n5
  n5 -->|dispatch_mw| n6
  n6past -.->|battery_partition| n7
  n6past -.->|battery_partition| n8
  n3past -.->|price_partition| n8
  n6past -.->|battery_partition| n9
  n4past -.->|carbon_partition| n9
  n4past -.->|carbon_partition| n10
  n10 -->|dispatch_mw| n11
  n11past -.->|battery_partition| n12
  n11past -.->|battery_partition| n13
  n3past -.->|price_partition| n13
  n11past -.->|battery_partition| n14
  n4past -.->|carbon_partition| n14
  classDef pastcopy fill:#F5F5F5,stroke:#4a7ba6,color:#000;
  class n0past pastcopy;
  class n1past pastcopy;
  class n2past pastcopy;
  class n3past pastcopy;
  class n4past pastcopy;
  class n6past pastcopy;
  class n11past pastcopy;

Ingests (in the stub: nothing)

The stub is data-free — every input is a literal constant in stub.go, with renewablePenetration exposed as the one swept driver. In the downstream application the residual-demand OU parameters (θ, σ) are fitted from NESO half-hourly demand data by OLS and SMC; the price and carbon models are calibrated against Elexon system prices and Carbon Intensity API series, and the battery parameters against BESS engineering data. The downstream real-world ingests are NESO demand, Carbon Intensity API, Sheffield Solar PV_Live, and Elexon BMRS series. In particular, where the downstream replays a measured carbon-intensity CSV, the stub generates carbon intensity structurally from residual demand (see carbon.go) — replay is data ingestion and stays downstream.

Assumptions

Validity regime

Failure modes

Question answered

As renewable penetration raises the volatility of residual demand, in which direction — and roughly how much — does each policy’s battery cycling (EFC) move, and how does a price-threshold battery compare to a carbon-threshold one under identical market conditions?

Generative behaviour under test

stub_test.go asserts, beyond “it runs”: 1. Harness — no NaNs, correct state widths, no params mutation, no statefulness residue across a repeated run (simulator.RunWithHarnesses). 2. Physical invariants (both policy chains) — state of charge stays inside [min_soc, max_soc] every step; actual dispatch never exceeds the power rating; cumulative EFC and cumulative CO₂ saved are non-negative and monotonically non-decreasing; carbon intensity stays non-negative. 3. Correct direction of parameter response (both policies) — raising renewablePenetration raises the ensemble-mean cumulative EFC for the price policy and the carbon policy (the observed penetration sweep is the first row of the generated Observed behaviour table below). Averaged over an ensemble so the claim is about the distribution, not one noisy realisation.

The expected-behaviour suite (behaviour_test.go) makes the decision-readiness explicit — each subtest is a named, plain-language response claim, with the observed number for each emitted by the test run into the Observed behaviour table below (never hand-typed):

Authoring this suite surfaced a real aliasing bug in the stub — two policy chains had shared a package-level params map, so a test overriding one chain’s threshold leaked into later runs. The fix (fresh per-call specs) is exactly the kind of defect the suite exists to catch.

Observed behaviour

Every row below is one bound object: a plain-language response claim, the test subtest that enforces it, and the number that test produced (ensemble values rounded to 2 dp). Nothing here is hand-written — the claims and their numbers are emitted by TestEnergyBalancerExpectedBehaviour (via go run ./cmd/model-graphs), so a claim cannot drift from its test or its result. If the model’s behaviour changes, either the binding test fails (a claim’s assertion broke) or TestCardsUpToDate fails (a number moved) — a broken claim cannot reach the card silently.

Response claim Enforced by Observed
Higher renewable penetration raises battery cycling (headline driver) TestEnergyBalancerExpectedBehaviour/higher_renewable_penetration_raises_cycling ensemble-mean cumulative EFC — pen 0.0 0.02 · 0.5 0.95 · 1.0 2.52
Higher discharge threshold reduces price-policy cycling TestEnergyBalancerExpectedBehaviour/higher_discharge_threshold_reduces_price_cycling ensemble-mean cumulative EFC — base 1.24 · price_high=£60 0.24
Larger battery capacity lowers the cycle count TestEnergyBalancerExpectedBehaviour/larger_battery_capacity_lowers_cycle_count ensemble-mean cumulative EFC — base 1.24 · 400 MWh 0.80
A persistently expensive market drains the battery (net seller) for positive revenue TestEnergyBalancerExpectedBehaviour/persistently_expensive_market_makes_battery_net_seller one expensive-market run (seed 42) — final SoC (MWh) 20.00 · revenue (£) 3476.94 (asserts final SoC (MWh) < initial SoC, revenue (£) > £0)
A persistently cheap market fills the battery (net buyer) at a cost TestEnergyBalancerExpectedBehaviour/persistently_cheap_market_makes_battery_net_buyer one cheap-market run (seed 42) — final SoC (MWh) 180.00 · revenue (£) -2033.69 (asserts final SoC (MWh) > initial SoC, revenue (£) < £0)
Lower round-trip efficiency reduces revenue TestEnergyBalancerExpectedBehaviour/lower_round_trip_efficiency_reduces_revenue ensemble-mean price-policy revenue (£) — base 3047.35 · η=0.75 -405.32
Higher price noise raises cycling TestEnergyBalancerExpectedBehaviour/higher_price_noise_raises_cycling ensemble-mean cumulative EFC — base 1.24 · σ=15 3.19
Steeper price sensitivity (mean held) raises cycling TestEnergyBalancerExpectedBehaviour/steeper_price_sensitivity_raises_cycling ensemble-mean cumulative EFC — base 1.24 · slope=0.004 3.38
Steeper carbon sensitivity (mean held) raises carbon-policy cycling TestEnergyBalancerExpectedBehaviour/higher_carbon_sensitivity_raises_carbon_cycling ensemble-mean carbon-policy cumulative EFC — base 0.55 · slope=0.020 2.13

Bespoke extensions (staged beside the stub)

ImbalancePriceIteration (imbalance_price.go), PriceThresholdDispatchIteration and CarbonThresholdDispatchIteration (dispatch.go), BatteryIteration (battery.go), BatteryDegradationIteration (battery_degradation.go), RevenueIteration (revenue.go) and CarbonSavingsIteration (carbon_savings.go) are custom simulator.Iteration implementations lifted verbatim from the downstream repo’s generative core.

CarbonIntensityIteration (carbon.go) is the one bespoke iteration written for the catalogue rather than lifted: it is the data-free generative counterpart of the downstream’s data-replay CarbonDataIteration, mirroring ImbalancePriceIteration’s structural form. The residual-demand and noise processes reuse the engine’s own OrnsteinUhlenbeckExactGaussianIteration, so no bespoke generator is needed there.

The data-fitting helpers that accompany these iterations downstream (OU parameter inference by OLS/SMC, price/carbon calibration, and the carbon-intensity CSV replay) are inference / ingestion concerns and were left downstream. These iterations live here rather than in engine core because the catalogue is the staging ground for the “should this be promoted into core?” question — a generic stock/SoC accumulator or a threshold-policy primitive recurring across other models would be the signal to promote, but that waits for the recurrence.

Downstream

Data ingestion (NESO / Carbon Intensity API / Sheffield Solar / Elexon), OU parameter inference (OLS + SMC), policy evaluation, and the dispatch decision layer live in the project repo:

https://github.com/umbralcalc/energy-balancer