Forward Forecasting logo FORWARDFORECASTING

Field Notes · Windward

Teaching an Agent to Read a Wind Farm

A LangGraph agent, real SCADA data from three open UK wind farms, and a physics equation from 1919 catch a sensor fault that a dashboard alone would have missed.

Fernando Borbón Wind energy background, renewable energy sector Live on AWS at windward.forwardforecasting.eu
Illustration of a wind farm at dusk with glowing gold data-lines connecting each turbine

I spent years in the renewable energy sector doing exactly this kind of work by hand: pulling SCADA data off real offshore turbines, building power curves, hunting for the sensor that's lying to you. So when I set out to build a portfolio project to sharpen a specific set of AI-engineering skills — LangGraph, RAG, agentic workflows, MLOps — I didn't want a toy dataset. I wanted the real thing.

From met masts to language models

My path through wind energy went in the same direction the wind's data flows. I started in wind resource assessment and meteorological instrumentation: met masts, cup and sonic anemometers, wind vanes and lidar, measuring the wind at a site before any turbine stands there, and learning how many ways an instrument can be wrong. From there I moved to numerical modelling of the wind resource, where a few months of measurements are extended across a whole site and over the long term with flow and weather models. Then the turbines were built and the question changed from "how much wind is there?" to "how much of it is each machine actually turning into energy?", and I spent years on SCADA data from operating wind farms: power curves, availability, and the turbine whose numbers do not add up.

For a long time those were three separate jobs, with separate tools and separate people. What changed recently is the fourth layer: large language models and agents that can read the outputs of all three and explain them in plain words. Windward is the first time I have put all four in one tool:

This is the write-up of Windward, and specifically of one moment about halfway through building it, when the agent flagged something that turned out to be real. The live dashboard is linked at the bottom of this post.

The setup: real turbines, not synthetic curves

Windward forecasts wind farm production and then reasons about how efficiently each turbine is actually extracting energy from the wind that's available to it. Three real, openly licensed SCADA datasets sit underneath everything — two from Cubico Sustainable Investments (Kelmarsh, Penmanshiel) and one from RES, on behalf of TRIG (Hill of Towie), each published via Zenodo under CC BY 4.0, and each in a different historian export format the pipeline has to normalize:

6, 14 & 21
turbines, Kelmarsh / Penmanshiel / Hill of Towie
12.3, 28.7 & 48.3
MW, Northamptonshire / Scottish Borders / Aberdeenshire
2016 & 2024
10-minute real SCADA, three farms

The forecasting target is real production; the forecasting input is real historical weather from Open-Meteo's ERA5 archive — deliberately not the turbines' own anemometers, because at forecast time an anemometer reading from the future doesn't exist. That distinction matters more than it sounds: it's the difference between a model that works in a demo and one that would actually work operationally.

Why gradient boosting, not a neural net

The forecasting model is a plain scikit-learn GradientBoostingRegressor (200 estimators, depth 4, learning rate 0.05) trained on seven physically-grounded features — wind speed, its cube (power scales with v³), direction, temperature, pressure, price, hour-of-day. For a tabular problem this size, gradient-boosted trees are the right default: they capture the non-linear power curve and interaction effects without a GPU, and they train on a laptop in seconds.

On held-out real data it lands at R² 0.73–0.81 across all three farms (Kelmarsh 0.73, Penmanshiel 0.81, Hill of Towie 0.74). That's a meaningfully harder number than an early synthetic-data prototype's R² 0.95 — real SCADA carries curtailment, wake losses and downtime the model has to learn around, which is the entire point of using real data instead of a clean synthetic curve.

Where the agent comes in

The forecast alone is just a number. The interesting part is what happens next: a LangGraph agent takes that forecast, compares it against what actually happened, computes real physics on top of it, retrieves grounding context from a real fault-event log, and writes a plain-English field report.

flowchart LR
    ingest["ingest\nreal weather + production"] --> forecast["forecast\nregistered MLflow model"]
    forecast --> diagnose["diagnose\npower curves, Cp, anomalies"]
    diagnose --> rag["rag\nFAISS retrieval"]
    diagnose --> multimodal["multimodal\nvision-LLM blade check"]
    rag --> recommend["recommend\nworst-turbine rule"]
    multimodal --> recommend
    recommend --> explain["explain\nAmazon Nova Lite"]
      
The agent workflow, agents/graph.py — seven nodes, each returning only the state it changed

The diagnose node is where the real engineering happens. For every turbine it bins the real SCADA into an IEC 61400-12-1–style power curve (0.5 m/s buckets) and computes the power coefficient Cp — actual power output divided by the kinetic power actually available in the wind hitting the rotor. Cp has a hard physical ceiling: the Betz limit, 16/27 ≈ 0.593, derived by Albert Betz in 1919. No turbine, no matter how well engineered, can cross it.

One turbine at Kelmarsh — turbine 5 — came back with a peak Cp of 0.611. Above the Betz limit. Which is, physically, impossible.

The agent didn't just print the number and move on. It flagged it as an anomaly, reasoned about the likely cause (nacelle anemometer bias — the sensor sits downstream of the spinning rotor, which is a known source of wind-speed measurement error), and said so explicitly in its field report rather than reporting a fabricated "the turbine is extracting more energy than physically possible" as if that were a real finding. That's the difference between a dashboard that shows you a number and an agent that understands what the number means.

Grounding it in something real: RAG over an actual fault log

The SCADA archives don't just contain production numbers — they contain the turbines' real status/fault event logs: timestamped Stop and Warning codes with human-readable messages ("Low gearbox oil pressure," "Timeout brake closed"). Rather than inventing a maintenance-manual corpus, I built the RAG stack over this real data: the ~60 longest-duration real fault events per farm, embedded with Amazon Titan Text Embeddings v2, indexed in FAISS, retrieved through LangChain.

Ask the agent "what causes a Cp reading above the Betz limit?" and it retrieves the actual methodology note and answers correctly, citing the source. Ask it which turbine has had the most maintenance interventions, and it can point at real event counts, not a guess.

A second, independent RAG stack — LlamaIndex, a different framework, over a different corpus shape (structured turbine spec metadata rather than incident narratives) — sits alongside it, deliberately, to exercise both toolchains rather than picking one and stopping.

The stack, briefly

LangGraphThe agent's state machine — seven composable nodes instead of one large function.
LiteLLMOne completion API across providers — only Bedrock is wired up today, but the agent code isn't locked to it.
LangChain + FAISSRetrieval over the real fault-event corpus, embedded with Bedrock Titan.
LlamaIndexA second, independent RAG stack over turbine spec metadata.
Amazon Nova LiteField-report generation and blade-photo vision inspection, via Bedrock.
MCP serverForecast, recommendation, RAG, OpenOA assessment and per-turbine questions exposed as tools any MCP client can call.
OpenOAThe national lab's open-source operational assessment: long-term AEP, electrical and wake losses, yaw, run offline.
MLflowSelf-hosted experiment tracking and per-farm model registry, S3-backed.
FastAPI + PydanticA REST layer that reuses the exact same typed schemas as the agent's tools.

What it costs to run

This is the part most portfolio projects skip, and it's the part I actually care about getting right: the whole thing — three farms, per-farm models, a RAG index each, a persistent live API — runs for under $0.10 a month, because (as the next two sections cover) it ended up hosted on infrastructure that was already running rather than anything new.

What grew since the first draft of this post

A few more pieces went in after this was first written, each closing a gap between "the roadmap says it's real" and it actually being real:

A fourth example, and a dead link on someone else's site

The next real dataset I went looking for was EDP's own "Wind Farm 1" open data — specifically because it has something the three UK farms above don't: a real on-site meteorological mast alongside the turbine SCADA and fault logbook, all for the same site. I found it. I also found that EDP's own download buttons for it are currently broken on their live site — "Download data" and "Download variables" both route to an internal admin path that returns Access Denied, confirmed with a real browser, not just a dead curl. Not a dataset problem; a site bug on infrastructure I don't control.

The same underlying farm turned out to be re-published, properly licensed and citable, inside a newer academic benchmark: CARE-to-Compare (Zenodo, CC BY-SA 4.0, published alongside a 2024 paper on wind turbine fault detection). Its "Wind Farm A" is EDP's Portugal site — but anonymized: no coordinates, no rated power or rotor diameter, no real calendar timestamps, and power readings rescaled to a normalized fraction rather than real kW, specifically so the source farm can't be identified. That rules it out for the forecast pipeline above, which needs real coordinates to join real weather against. What it does have: 22 independent, real, labeled case studies — 11 with an actual root-cause description (gearbox failure, generator bearing failure, transformer failure, hydraulic group) — genuinely better ground-truth anomaly labels than any of the other three farms carry.

So it became its own thing rather than a fourth entry forced into the same farm abstraction: its own loader, its own RAG corpus and FAISS index, its own /edp/* API routes, its own dashboard tab — real power curves and status-code breakdowns per case study, and a RAG-grounded "ask the agent" box over the real fault descriptions. No forecast, no LangGraph run, no MLflow model, because there's no real location to forecast against. Live on its own tab at the dashboard linked below.

Separately, since I'd been pointed at Yu Ding's Data Science for Wind Energy, I tried applying one of its published methods — a Gaussian-process-based statistical test (dswe.ComparePCurve) for whether two turbines' power curves differ significantly — to put a real p-value behind the anemometer-bias displacement check above, instead of just reporting a raw shift. Installed the real package, ran it against real Kelmarsh data, and hit two reproducible bugs in the published PyPI release against current numpy/scipy. Documented in the repo, not shipped: a broken dependency patched over quietly is worse than an honest "evaluated, not pursued."

Then the project's purpose changed, so I deployed it for real

The original plan was to keep running this the same lightweight, low-commitment way it ran during development — fine for a portfolio piece. That logic stopped applying the moment this stopped being a skill demo and started being something I intend to keep running and eventually combine with an energy-price-prediction model and commercialize. A commercial service doesn't get to run on infrastructure sized for a demo — it needs a real home, on infrastructure I actually operate day to day.

So I stood up the real thing: self-hosted MLflow (a small systemd service, SQLite backend, S3-backed artifact store), and the FastAPI + agent service deployed as a real, persistent Docker container — both running on forwardforecasting-dev, an EC2 instance I already had, which previously auto-shut-down after 30 minutes idle (a cost-saving habit from when it was a personal dev sandbox). That automation is now disabled for this project specifically, because a service that stops itself isn't a service.

curl https://windward.forwardforecasting.eu/forecast -X POST -d '{"farm_id":"kelmarsh","horizon_hours":6}' — real HTTPS, real Let's Encrypt cert, real model inference, publicly reachable, running continuously for under $0.10/month.

One genuine bug along the way, the kind you only find by actually deploying: the container couldn't reach the MLflow server on 127.0.0.1:5000, even though that's exactly where it was listening — because inside a Docker container, 127.0.0.1 is the container's own loopback, not the host's. The fix was network_mode: host, which for a single-container deployment on infrastructure I already control was simpler than the alternative (a Docker bridge network plus an explicit gateway route).

Measuring the farm the way the industry does: OpenOA

Everything above reads the farms from the turbines' point of view. The questions an owner or a lender actually asks are different: how much energy will this site make in a normal year, and how sure are you? How much is lost in the cables? How much to wakes? The National Laboratory of the Rockies (formerly NREL) publishes an open-source library for exactly that, OpenOA, along with the papers behind its methods. I ran four of its analyses on all three farms, over the full 2016-2021 data for the two Greenbyte farms rather than the single year the forecasting uses.

0.3-1.3%
electrical losses, turbines vs the substation meter
31.7 & 76.3
GWh/yr long-term P50, Kelmarsh / Penmanshiel
7.8% & 6.4%
long-term wake losses, Kelmarsh / Penmanshiel

Three findings were worth the effort.

The obvious meter was the wrong one. Each Greenbyte dataset ships two candidate grid meters. One of them recorded slightly more energy at the grid connection than the turbines produced, which no real meter can do. The substation unit next to it showed a plausible ~1% loss, so that is the one the analysis uses.

The papers' claims mostly held up on UK data. A 2021 study found that fitting the yield regression on daily data with temperature, instead of the industry's monthly linear fit, cuts its uncertainty. On these farms it did: Kelmarsh went from ±1.17% to ±0.95%, Penmanshiel from ±1.51% to ±1.10%. A 2020 paper showed that adding uncertainty components as a root-sum-of-squares, the usual shortcut, assumes they are independent when they are not. Here the shortcut understated Kelmarsh's total and slightly overstated Penmanshiel's: not reliably in either direction, which is the real argument for running the full Monte Carlo.

The agent's top recommendation needed a caveat. The agent had been sending a crew to Kelmarsh 6: lowest capacity factor in the fleet, under its neighbors 15% of the time. OpenOA puts its long-term wake loss at 21.7%, against 7.8% for the farm. A good part of its shortfall is where it stands, not what is wrong with it, and the recommendation now says so and points at the turbine's outage history first.

And the post's headline anomaly? Kelmarsh 5's "impossible" Cp of 0.611 fell to 0.594 once real air density replaced the sea-level constant, and to 0.585, under the Betz limit, once OpenOA-style quality control removed the hours a stopped or derated turbine had been dragging into its curve. The agent was right to refuse "more than physically possible" as a finding. The cause was the data, not the anemometer.

Not everything survived contact with real data. OpenOA's yaw-misalignment method, which its own authors mark as unvalidated, gave every Kelmarsh turbine the same suspicious offset that swung by up to 34° between wind speeds, so those results are reported as inconclusive. On Hill of Towie it could not run at all: that export has no wind-vane channel, and the substitute signal barely moves. Three Penmanshiel turbines did produce consistent, tight estimates (+3.7° to +4.7°) and are flagged for a vane check. OpenOA itself runs offline in its own environment, because it pins older versions of the libraries the live models depend on. The service only reads its results.

Asking about one turbine

The "ask the agent" box used to answer for the farm as a whole. It now takes questions about single turbines too: which turbine performs best? when was turbine 3 last maintained? which one loses most to wakes? It answers from the real status logs and the OpenOA results. Kelmarsh 3's last maintenance stop was 9 November 2016, 12:15 to 13:18, a manual on-site stop. The same tools are exposed on the MCP server, so an external agent can ask them too.

Testing it turned up one more real-data trap. A single stoppage is often logged as several alarms at once (Kelmarsh 1's three generator fans each raised one), so simply adding up alarm durations put Kelmarsh 1 at 1,156 hours of forced outage in 2016. Counted as overlapping time, the real figure is 507.7.

Try it

The live dashboard at windward.forwardforecasting.eu lets you switch between farms, see the actual-vs-forecast production, the power curves against the Betz limit, the agent's field report for each, a real vision-LLM blade inspection, and a live "ask the agent" box grounded in that farm's own data — served straight off the EC2 instance, no sandboxed viewer-billed capability involved. A fourth tab, EDP Wind Farm A, swaps the forecast for 22 real labeled fault case studies. (The Spain day-ahead price model that used to live on this page moved out into its own project, energy-trader, once it grew a battery optimizer to go with it.) Or hit the API directly: curl https://windward.forwardforecasting.eu/forecast -X POST -d '{"farm_id":"kelmarsh","horizon_hours":6}'.

See it live

Real data, real models, real anomaly, real deployment — not a slide deck.

Open windward.forwardforecasting.eu →

About the cover image: the turbines are linked by thin gold data-lines rather than shown in isolation — a visual stand-in for what the agent actually does: read every turbine's SCADA signal together, not one at a time, which is how it caught Kelmarsh 5's impossible Cp reading in the first place. ImageModelstability.stable-image-core-v1:1 · AWS Bedrock (us-west-2)Prompt“Editorial illustration of a row of wind turbines on rolling hills at dusk, deep navy sky, thin glowing gold data-connection lines linking each turbine like a neural network overlay, minimalist flat vector illustration style, clean linework, no text, no logos, atmospheric and technical mood”