A real-time digital twin of an actual wind farm — interactive 3D control room, Rust streaming backend, physics engine, and machine-learning fault detection — built entirely on public data: the ENGIE La Haute Borne open SCADA dataset (4 × Senvion MM82, 2.05 MW each, Meuse, France).
The ML layer detected the farm's labeled, real-world gearbox-bearing failure
(turbine R80721, 2015-01-02) 93.7 hours before it was recorded, using a model
trained only on a different year's data. Details and full metrics:
data/models/training-report.md.
Captured by the Playwright smoke test: npm run test:e2e with the Rust backend
running regenerates docs/screenshots/control-room.png (against the mock it writes
to test-results/ instead, so the committed image always shows the full stack).
- 3D control room — four turbines at their real surveyed positions (OpenStreetMap nodes cross-checked against the NREL OpenOA asset table), with nacelle yaw, rotor RPM, and per-blade pitch animated from the live telemetry stream. Fleet KPI bar, per-turbine detail panels with gauges and sparklines, alarm feed (clicking an alarm flies the camera to the affected turbine), scenario picker, replay-speed control, and camera view presets (Overview / T01–T04).
- Real-data backend — a Rust server that replays the actual ENGIE archive (217,588 SCADA rows, 2017-01 → 2018-01) at 1–600× speed, enriches it with real historical weather from Open-Meteo, derives status and alarms with deterministic rules, and persists everything to a SQLite historian queryable over REST.
- Physics engine — every streamed frame carries expected power, power residual, wake losses (Jensen/Katic between the four real positions), free-stream wind, Cp, expected gearbox temperature, and thermal residual. Every number traces to a published equation or curve (see Physics verification below).
- Machine learning — per-frame anomaly score (Isolation Forest implemented in-crate, ~200 lines, no external ML dependency), +1 h / +6 h power forecasts (RandomForest), and a CUSUM health index — all trained and scored on the real archive, surfaced in the UI as anomaly bars, forecast continuations, and health readouts.
- Scenarios grounded in reality —
high_wind,curtailment, andicingjump to windows discovered by scanning the archive at startup (no hardcoded timestamps);fault_t03grafts a deterministic +1.5 °C/h gearbox-temperature drift onto the real T03 signal so the full rule chain (THERMAL_RESIDUAL_HIGH→GEARBOX_TEMP_HIGH→GEARBOX_TEMP_TRIP) and the ML anomaly alarm can be watched end to end.
npm run setup # one-time: install root + frontend + mock-server deps
npm run demo # mock server (:8090) + frontend dev server (:5173) togetherOpen http://localhost:5173. Click a turbine (in 3D or the left rail) to fly the camera to it, or use the View presets. The top-right panel switches scenarios and replay speed.
cargo run -p twin-server # pivots the archive (~1 s), fetches weather, serves :8090
npm run dev # frontend, unchanged — Vite proxies /ws → :8090The Rust server speaks the identical schema-v1 WebSocket protocol on the same port as the Node mock, so the frontend works against either with zero changes (stop the mock first — it holds :8090).
On a fresh clone this replays June 2017. The full 217,588-row extract is 178 MB
and gitignored; the repo ships one real month
(data/la-haute-borne/sample-2017-06.csv), and the server falls back to it with a
warning when the full archive is absent. For the whole year — and for the scenario
windows and ML training that need it — download the extract first:
curl -L -o data/la-haute-borne/la-haute-borne-data-2017-2020.csv \
https://media.githubusercontent.com/media/Loubout/la_haute_borne_data/main/la-haute-borne-data-2017-2020.csvSources, mirrors, the optional 2013–2016 extract, and the full column dictionary:
data/README.md.
Likewise ML is off until you train it: the 85 MB model bundle is gitignored, so a
fresh clone logs ML model unavailable — ML disabled and serves physics-only
telemetry. Regenerate it with
cargo run --release -p twin-ml --bin twin-ml-train (~85 s, needs the full archive).
| Command | What |
|---|---|
npm run dev |
frontend only (Vite dev server, proxies /ws → :8090) |
npm run mock |
mock telemetry server only (2 Hz, schema v1) |
npm test |
Vitest suite: schema (zod), store, ring buffer, scenario parser, contract |
npm run test:e2e |
Playwright smoke test (boots mock + dev server, drives headless Chromium). Probes /api/health: against the mock it asserts the physics/ML blocks are absent, against a running twin-server it gates the full physics + ML UI |
npm run build |
tsc --noEmit + production build of the frontend |
npm run lint |
ESLint across frontend, mock server, and the test suites |
cargo test --workspace |
Rust unit + integration tests (ephemeral ports, sample CSV) |
cargo clippy --workspace --all-targets -- -D warnings |
lint |
cargo fmt --check |
formatting |
cargo run --release -p twin-ml --bin twin-ml-train |
retrain + rescore the ML models (~85 s; ~8.5 min with the 2013–2016 extract) |
cargo run --release -p twin-physics --bin twin-physics-verify |
physics verification (see below) |
Mock server modes:
npm run mock # live synthetic sim
node mock-server/index.js --replay mock-server/fixtures/replay-sample.jsonl # replay
node mock-server/tools/verify-stream.mjs # schema smoke checkmock-server/fixtures/replay-sample.jsonl is a committed 120-frame replay fixture
converted from the real ENGIE SCADA sample (mock-server/tools/csv-to-jsonl.js); the
contract test in tests/contract.test.ts validates every line against schema v1 and
doubles as the Rust backend's acceptance test.
ENGIE SCADA CSV ──► twin-ingest ──► replay clock ──► status/alarm rules ─┐
Open-Meteo API ───► weather cache ───────────────────────────────────────┤
▼
twin-physics (expected power, wakes,
thermal residuals) ──► twin-ml
(anomaly score, forecasts, health)
│
┌──────────────────────────────────────────┤
▼ ▼
SQLite historian WebSocket (schema v1,
(telemetry + alarms, 2 Hz telemetry + alarms)
REST query API) │
▲ ▼
└──────────── frontend: WsClient (reconnecting,
zod-validated) → mutable telemetry lane
(R3F useFrame, zero React re-renders)
+ 2 Hz snapshots in a zustand store
(KPI bar, turbine rail, ECharts detail
panel, alarm feed, scenario controls)
Rust workspace (crates/):
| Crate | Role |
|---|---|
twin-protocol |
schema-v1 wire types + contract test vs the committed replay fixture |
twin-ingest |
CSV pivot, replay clock, status/alarm rules, scenario window discovery, Open-Meteo client |
twin-physics |
air density, IEC bin power curve, Jensen/Katic wakes, gearbox thermal model, verify binary |
twin-ml |
Isolation Forest (in-crate), RandomForest forecasting, CUSUM health index, training binary |
twin-server |
axum HTTP+WS server, SQLite historian, wiring (binary) |
frontend/ 3D control room (Vite + TS + React Three Fiber)
mock-server/ Node WebSocket mock implementing telemetry schema v1 (disposable)
tests/ Vitest unit + schema-contract tests
tests-e2e/ Playwright end-to-end smoke test
crates/ Rust workspace (see table above)
data/ La Haute Borne SCADA + data dictionary (see data/README.md),
trained model bundle + training report (data/models/)
docs/ user guide, architecture diagram, screenshots
Docs: docs/user-guide.html walks the control room panel by
panel; docs/architecture.svg is the diagram version of the
dataflow above. Contributing: CONTRIBUTING.md.
| Endpoint | Purpose |
|---|---|
GET /ws |
schema v1 stream (hello / 2 Hz telemetry / alarms; set_scenario, set_speed) |
GET /api/health |
{ "status": "ok", "archive_rows": n, "weather": bool } + ML training window |
GET /api/farm |
serves frontend/public/config/farm-layout.json |
GET /api/telemetry/history?turbine=T03&from=…&to=… |
historian query (RFC 3339 bounds; invalid bounds → 400, DB failure → 500) — includes physics and ML columns |
GET /api/alarms?from=…&to=… |
alarm history with cleared_ts (same error semantics) |
crates/twin-server/config.toml (relative paths resolve against the file); every
key is overridable via TWIN_* env vars. Common keys:
| Key | Env var | Default |
|---|---|---|
listen |
TWIN_LISTEN |
127.0.0.1:8090 |
archive_path |
TWIN_ARCHIVE_PATH |
data/la-haute-borne/la-haute-borne-data-2017-2020.csv |
archive_fallback_path |
TWIN_ARCHIVE_FALLBACK_PATH |
data/la-haute-borne/sample-2017-06.csv (used when archive_path is missing; "" disables) |
fetch_weather |
TWIN_FETCH_WEATHER |
true |
physics_enabled |
TWIN_PHYSICS_ENABLED |
true |
wake_decay_k |
TWIN_WAKE_DECAY_K |
0.075 |
ml_enabled |
TWIN_ML_ENABLED |
true |
ml_model_path |
TWIN_ML_MODEL_PATH |
data/models/ml-model.json |
historian_retention_sim_days |
TWIN_HISTORIAN_RETENTION_SIM_DAYS |
30 |
Weather is cached at data/cache/open-meteo-2017.json (gitignored); offline, the
server just omits the optional weather field. Historian: data/historian.db
(gitignored, WAL), batched writes, retention pruned at startup and every 10 minutes
— telemetry and cleared alarms; open alarm episodes are never pruned. The fitted
physics model is cached at data/cache/physics-model.json keyed by the archive
content hash.
The physics layer is verifiable end to end:
cargo run --release -p twin-physics --bin twin-physics-verify -- \
--turbine T03 --from 2017-06-25 --to 2017-06-26 \
--weather data/cache/open-meteo-2017.jsonThis prints the full computation chain for every 10-min interval — measured wind → density → sector free-stream → pairwise wake deficits with geometry → wake-adjusted wind → curve lookup → expected vs actual → residual → thermal chain — and exits nonzero on FAIL. Results on the full 2017 archive (217,588 rows):
- Betz bound: max implied Cp = 0.525 over all bins (limit 0.593 + 0.02)
- Manufacturer agreement: operational vs digitized MM82 curve MAE = 121.7 kW (5.9% of rated) in the 6–14 m/s region
- Rated plateau: bins ≥ 12 m/s mean 1965 kW (−4.1% of 2050)
- Thermal fits: τ = 5.6 / 6.5 / 6.2 / 7.7 h (T01–T04), fit R² = 0.97–0.997, healthy |residual| median = 1.34–1.70 °C
Equations and sources: air density from the ideal gas law (ISO 2533 fallback); IEC 61400-12-1 bin-method power curve (0.5 m/s bins, per-bin median of the archive itself) with density correction; Jensen/Park wake deficit (k = 0.075) with Katic root-sum-of-squares multi-wake and closed-form partial-overlap geometry; per-10°-sector most-upwind-turbine free-stream reference; lumped first-order gearbox thermal model (asymmetric heat/cool time constants, least-squares fit on trailing 90 healthy days). Hand-computed wake, circle-intersection, Katic, and thermal step-response cases are pinned as unit tests.
Full training report (regenerate with
cargo run --release -p twin-ml --bin twin-ml-train):
data/models/training-report.md.
- Anomaly detection — Isolation Forest (Liu, Ting, Zhou 2008: 200 trees, ψ = 256,
s = 2^(−E(h)/c(ψ)), withc(2) = 1andc(256) ≈ 10.2487pinned as hand-computed tests) over 15 physics-residual features, including two deviation features relative to each turbine's adaptive EWMA baseline (τ = 14 days) and a second drift-subset forest;anomaly_score = max(full, drift). AlarmML_ANOMALY_HIGHat score > 0.75, hot-side gated, sustained ≥ 6 frames. - Power forecasting — one RandomForest per horizon (300 trees, depth 12, seeded),
pooled across turbines with a one-hot; lags, physics expected power, hour/direction
harmonics, and Open-Meteo archive wind at +h as the
forecast_windstand-in (a live deployment swaps the forecast API unchanged). Chronological split (train 2017-01→09, validation 2017-10, test 2017-11→2018-01), benchmarked against persistence and climatology. - Health index — one-sided CUSUM (k = 5 %, h = 20 %) over daily-mean power residuals: the weeks-scale degradation signal.
Headline metrics on the full archive:
| Metric | Value |
|---|---|
| Real 2015 R80721 gearbox failure (model trained only on 2017 data) | detected — first ML alarm 93.7 h before the recorded failure |
| Forecast skill vs persistence, +1 h (test) | +5.2 % (RMSE 239.3 vs 252.4 kW) |
| Forecast skill vs persistence, +6 h (test) | +34.2 % (RMSE 314.4 vs 477.8 kW) |
| Curtailment window alarms | none (specificity holds) |
| Healthy-month (2017-06) false positives | 0 episodes per turbine |
| Graft detection (AUC vs healthy month) | 0.79 |
| Graft lead vs the first static threshold warning | −160 min (the static rule fires first — physics of why in the report's Limitations) |
One event is evidence, not a statistic, and the +1 h skill sits below typical literature values — the training report's Limitations section covers both plainly, along with the measured iterations behind the detector (deviation features vs adaptive baselines, drift-subset forest, hot-side gating, sensor-artifact hygiene, post-jump settling window).
React Three Fiber scene: 4 procedural turbines (tower/nacelle/rotor/blades as
separate nodes — nacelle yaw, rotor RPM, and per-blade pitch all driven by
telemetry) at the real farm layout (frontend/public/config/farm-layout.json),
physics-driven wake cones (length = distance at which the Jensen deficit falls below
2%, opacity ∝ base deficit), time-of-day sun from the simulated clock, fault
beacons, icing tint, EXPECTED-vs-ACTUAL overlay on the power sparkline with a dotted
+1 h forecast continuation, anomaly bars (green→amber→red), and Physics / ML
toggles (ML requires physics — its features are physics residuals).
An agentic AI copilot for the control room: a local, open-source LLM (via Ollama) connected to the twin through a tool interface over the historian, physics engine, ML models, and alarm system — ask "why is T03 running hot?" and get an answer backed by evidence, with the copilot flying the camera to the turbine in question. The tools it will call already exist and are tested.
Code: MIT — see also CONTRIBUTING.md,
SECURITY.md (this is a localhost demo with no authentication — read
it before exposing the server), and CHANGELOG.md.
SCADA data: ENGIE La Haute Borne open dataset (CC BY 4.0, via
Mendeley Data DOI 10.17632/vmyg4yp3s8.1). Weather data by
Open-Meteo.com (archive API, free, no key). Turbine
positions: © OpenStreetMap contributors (ODbL), cross-checked against the NREL
OpenOA asset table. The 3D turbine is procedural (no external asset) — history in
frontend/public/models/ATTRIBUTION.md.
