The lake streams, the solver drinks. A single WebSocket carries the whole conversation between GDA and GridSim — the topology once, a fully solved state of Great Britain every settlement period, and six on-demand query operations, each answering with a complete solve document. This page is the protocol reference. It is short, because the protocol is small — the frames are where the country lives.
No message broker, no schema registry, no binary framing to argue about in code review. One socket carries both the continuous state stream and the request/response traffic — a design decision I made once and have never had to defend, because a settlement period is thirty minutes long and a frame is a few megabytes. This is not a throughput problem.
The C# client is pure Core — no UI dependencies, no live server required — which is precisely what makes the protocol testable rather than merely observed to work. BridgeProtocolTests exercises every message kind against canned payloads on the client side, and a streaming test runs the full session against both zone-nged (1,292 buses) and gb-full (3,539 buses) before I call anything done.
The client speaks in actions; the server answers in frames. Start a session, drive it like a video player, ask questions while it runs. Everything below is a literal wire message.
// begin a replay session
{ "action": "start",
"case": "gb-full",
"start": "2026-06-10T00:00:00Z",
"fps": 2,
"source": "presolved" }
// video-player controls
{ "action": "set_speed", "fps": 8 }
{ "action": "seek", "to": "2026-06-10T17:30:00Z" }
{ "action": "stop" }
// ask a question mid-stream — answered inline
{ "action": "query",
"op": "inertia",
"at": "2026-06-10T14:30:00Z" }Two ways to feed the stream. "live" solves each frame as it is requested — useful for development, honest but slower. "presolved" replays the pre-solved lake frame-by-frame: every settlement period was solved once, corrected against the public record, and stored, so per-bus state genuinely varies each tick. That is the true replay — not an animation of one solve, but 3,539 buses moving because Britain moved.
The desktop client requests source=presolved by default. If you build your own client, so should you.
The first thing down the socket after start is the model block of a gridsim-solve-export/1 document: every bus with its X/Y coordinates for the map, every generator, every branch. The client caches it and rebuilds every subsequent frame against it — the state stream never repeats structure, only values.
This is why a state frame stays small enough to stream at 8 fps while describing a country: the wiring diagram travelled once, at second zero, and never again.
{
"type": "topology",
"schema": "gridsim-solve-export/1",
"case": "gb-full",
"model": {
"buses": [
{ "id": 1, "name": "BEAU4A",
"baseKv": 400.0,
"x": 217432.5, "y": 846911.0 },
{ "id": 2, "name": "BEAU2B",
"baseKv": 275.0,
"x": 219880.1, "y": 843207.4 }
// … 3,537 more
],
"generators": [
{ "id": "T_DRAXX-1", "bus": 1204,
"fuel": "BIOMASS",
"pMaxMw": 645.0, "msgMw": 322.0 }
// … the fleet
],
"branches": [
{ "from": 1, "to": 2,
"rPu": 0.0004, "xPu": 0.0121,
"ratingMva": 1390.0 }
// … 5,917 more
]
}
}The workhorse. Per-bus electrics, per-branch flows, the system scalars, the stress overlays, the operator's situational block and the market's money block — everything the desktop app renders arrives in this one message, thirty minutes of Britain at a time.
{
"type": "state",
"t": "2026-06-10T14:30:00Z", // the tick — settled past only
"result": {
"buses": [ // × 3,539
{ "vm": 1.012, "vaDeg": -14.62,
"pInjMw": 41.8, "qInjMvar": 9.3 }
// …
],
"lineFlows": [ // × 5,918
{ "pFromMw": 612.4, "qFromMvar": 88.1, "loadingPct": 44.1 }
// …
]
},
"scalars": {
"freqHz": 49.987,
"rocofHzPerS": -0.004,
"genMw": 34710.0,
"demandMw": 31240.0,
"inertiaMwS": 214800.0,
"conditionIndex": 0.91,
"rocofHeadroomHzS": 0.121
},
"overlays": {
"stressByBranch": [ 0.44, 0.12, /* … × 5,918 */ ],
"conditionByBus": [ 0.97, 0.89, /* … × 3,539 */ ]
},
"operator": {
"largestLossMw": 1220.0,
"reserveMarginMw": 2480.0,
"deratedCapacityMargin": 0.087,
"lolpDrm": 0.0002,
"netInterconnectorMw": 3260.0,
"balancingActions": [
{ "bmUnit": "T_DRAXX-1", "mw": 120.0,
"fuel": "BIOMASS", "synchronous": true,
"costGbp": 10400.0 }
// … every acceptance, fuel-tagged, synchronous-tagged
],
"constraints": [
{ "boundary": "B6", "costGbp": 412000.0 }
],
"warnings": [],
"synchronousInertiaGvaS": 57.9,
"outturnInertiaGvaS": 64.2,
"marketInertiaGvaS": 6.3
},
"market": {
"imbalancePriceGbpMwh": 92.40,
"marketIndexPrice": 78.15,
"bsuosGbpMwh": 4.71,
"dailyConstraintCostGbp": 1830000.0,
"gridCarbonGCo2Kwh": 142.0,
"interconnectors": [
{ "name": "IFA", "mw": 998.0 },
{ "name": "IFA2", "mw": 742.0 },
{ "name": "BritNed", "mw": 612.0 },
{ "name": "NemoLink","mw": 488.0 },
{ "name": "ElecLink","mw": 501.0 },
{ "name": "NSL", "mw": 702.0 },
{ "name": "Viking", "mw": -388.0 },
{ "name": "EWIC", "mw": -212.0 },
{ "name": "Moyle", "mw": -80.0 },
{ "name": "Greenlink","mw":-103.0 }
]
}
}Not after — before. A future-dated tick is treated as a protocol violation and refused; the client never constructs, caches or renders a frame the guard has not passed. There is no configuration flag to relax this, because it is the architecture, not a setting. The bridge can only ever lag reality.
| Field | Unit | Meaning |
|---|---|---|
| scalars — the system at a glance | ||
| freqHz | Hz | System frequency at the tick, from the recorded 1-second archive — not a solver output, a measurement. |
| rocofHzPerS | Hz/s | Rate of change of frequency at the tick. The number the whole inertia debate is about. |
| genMw | MW | Total generation dispatched in the frame, summed over the fleet. |
| demandMw | MW | Total demand carried, summed over the 1,141 load points. |
| inertiaMwS | MW·s | Total stored kinetic energy behind the frame — the ½Jω² sum, not a proxy. |
| conditionIndex | 0–1 | System condition index: a composite of voltage health, loading and margin. 1.0 is a boring, well-behaved grid. Boring is good. |
| rocofHeadroomHzS | Hz/s | Distance between the RoCoF the largest credible loss would cause and the relay limit. When this approaches zero, the operator block starts to matter. |
| operator — the situational block | ||
| largestLossMw | MW | Largest credible infeed loss at the tick — the biggest single thing that could trip. |
| reserveMarginMw | MW | Operating reserve held above demand at the tick. |
| deratedCapacityMargin | ratio | De-rated capacity margin — available capacity after availability de-rating, over demand, minus one. |
| lolpDrm | prob. | Loss-of-load probability implied by the de-rated margin. Usually a very small number; the frames where it isn't are the interesting ones. |
| netInterconnectorMw | MW | Net interconnector position — positive is importing. Asynchronous by construction, which is why the inertia split below exists. |
| balancingActions | list | Every BM acceptance in the period, each tagged with fuel and a synchronous flag — so you can see not just what was bought, but whether it spins. |
| constraints | list | Active boundary constraints with their cost: {boundary, costGbp}, against the real ETYS boundaries (B4, B6, B7, B8, SEIMP). |
| warnings | list | Anything the frame builder wants on the record — empty in the frame above, which is how I like it. |
| synchronousInertiaGvaS | GVA·s | Inertia from synchronous plant actually spinning in the frame. |
| outturnInertiaGvaS | GVA·s | NESO's published outturn inertia for the period — the public record the frame is pinned to. |
| marketInertiaGvaS | GVA·s | Inertia procured through the stability market. The gap between these three numbers is a story this platform exists to tell. |
| market — the money block | ||
| imbalancePriceGbpMwh | £/MWh | System imbalance price for the settlement period. |
| marketIndexPrice | £/MWh | Market index price — the reference the imbalance price is judged against. |
| bsuosGbpMwh | £/MWh | Balancing services use-of-system charge for the period. |
| dailyConstraintCostGbp | £ | Running constraint cost for the day the tick sits in. |
| gridCarbonGCo2Kwh | gCO₂/kWh | Grid carbon intensity at the tick. |
| interconnectors | list | All ten named interconnector flows, signed — positive imports, negative exports. |
The response to a query. The payload's solveExport is a complete gridsim-solve-export/1 document — the same format the desktop importer eats — fed straight through, so every on-demand answer lands with its full math ladder attached: the model, the solution, the residuals, the verdict.
The consequence is worth spelling out: there is no "summary API" that returns a bare number you have to take on faith. If the bridge tells you the system had 57.9 GVA·s of synchronous inertia at 15:52 on 9 August 2019, the document that proves it came in the same envelope.
{
"type": "result",
"op": "inertia",
"at": "2026-06-10T14:30:00Z",
"payload": {
"systemGvaS": 64.2,
"bySource": {
"synchronousGvaS": 57.9,
"marketGvaS": 6.3
},
"solveExport": {
"schema": "gridsim-solve-export/1",
"model": { /* full network */ },
"solution": { /* full solved state */ },
"residuals":{ "maxPMismatchMw": 3.2e-13 }
}
}
}Each op takes a historical instant, solves against it, and returns a verdict with the full solve document attached. And each op asserts the horizon itself: an at past the cutoff returns verdict INFEASIBLE_HORIZON — not an error, a verdict. The refusal is part of the API.
System inertia at the instant, split per-source in GVA·s — synchronous plant, market-procured, and the outturn record they should reconcile against.
{"action":"query","op":"inertia",
"at":"2026-06-10T14:30:00Z"}The system condition index plus the per-bus breakdown — which of the 3,539 buses were pulling the average down, and by how much.
{"action":"query","op":"condition",
"at":"2026-06-10T14:30:00Z"}Inject a grounded counterfactual at a historical instant — a trip, a loss, a device — and get a verdict: SECURE or INSECURE, with the RoCoF that decides it.
{"action":"query","op":"scenario",
"at":"2019-08-09T15:52:00Z",
"inject":{"infeedLossMw":1400}}Add load or generation at a bus and ask whether the network takes it: a FEASIBLE verdict with the headroom in MW. Representative result: a 50 MW load-add returns FEASIBLE with over 800 MW of headroom to spare.
{"action":"query","op":"feasibility",
"at":"2026-06-10T14:30:00Z",
"bus":"BEAU4A","addLoadMw":50}Reconstruct a recorded event window and get the full dossier back — the states through the window, the frequency trace, the actions taken, the money spent.
{"action":"query","op":"post_event",
"from":"2019-08-09T15:40:00Z",
"to": "2019-08-09T17:00:00Z"}Per-asset drill-down at the instant: a generator's operating envelope (MSG, Pmax), a BESS state of charge, a tap-changer's position and accumulated wear, a consumer point's demand.
{"action":"query","op":"asset",
"at":"2026-06-10T14:30:00Z",
"assetId":"T_DRAXX-1"}One rule binds all six: ask about an instant inside the guard window and you get INFEASIBLE_HORIZON. Every time. There is no query shape, flag or retry that produces a forecast, because the machinery that would compute one was never built.
The reason replay feels like video rather than batch processing: every settlement period is pre-solved once and stored — topology.json alongside hive-partitioned year=/week= parquet under presolved_state/<case>/. Streaming replays stored frames rather than re-solving live, and corrections pin each frame to the public record — NESO's outturn, Elexon's settlement — so what streams is not merely a solution, it is the solution, reconciled.
When a frame does need solving — a query op, a live session — the previous period's state is the warm start. Thirty minutes of Britain rarely moves the operating point far, which is why the country re-solves in 1–3 iterations, around 60 ms, during replay. The lake does the remembering; the solver does the finishing.
presolved_state/
└── gb-full/
├── topology.json # the model, once
├── year=2025/
│ ├── week=49/state.parquet
│ └── week=50/state.parquet
└── year=2026/
├── week=01/state.parquet
├── …
└── week=23/state.parquet
# one row per
# settlement periodThree message kinds and six ops are easy to write down. The 225 GB, 653-dataset, provenance-stamped estate that makes every frame true is the part that took the years. Bridge streaming is a Professional-tier capability.
Wire examples on this page are representative frames from a gb-full replay session; field values vary by settlement period, structure does not.