Forward Forecasting logo FORWARDFORECASTING

Field Notes · Windward

Teaching an Agent to Read a Wind Farm

A LangGraph agent, real SCADA data from two 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, CENER Built on Azure ML + AWS Bedrock

I spent years at CENER, Spain's national renewable energy research centre, 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.

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. Two real, openly licensed SCADA datasets from Cubico Sustainable Investments (published via Zenodo, CC BY 4.0) sit underneath everything:

6 & 14
turbines, Kelmarsh / Penmanshiel
12.3 & 28.7
MW, Northamptonshire / Scottish Borders
2016
10-minute real SCADA, both 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 — which mattered, because a design constraint of this whole project was staying inside Azure's free tier.

On held-out real data it lands at R² 0.73–0.81 depending on the farm. 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 — Bedrock today, a one-line swap to Azure OpenAI.
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 and RAG-query exposed as tools any MCP client can call.
Azure ML + MLflowExperiment tracking and per-farm model registry — no compute cluster, training runs locally.
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 — two farms, per-farm models, a RAG index each, daily agent runs — comes to roughly $0.05–$0.35 a month. Training runs locally, the Azure ML workspace holds no compute cluster, and the AI calls (Nova Lite narration, Titan embeddings) are priced in fractions of a cent.

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:

Proving Kubernetes, then turning it off

The last unchecked box was AKS. Rather than leave a claim like "Kubernetes-ready" sitting in a README unverified, I actually built and pushed the container, stood up a real cluster, deployed it, and hit the running service over the network — then deleted everything within the hour.

kubectl get podswindward-api-5fc6668fff-7dnd8   1/1   Running. curl /health through a live AKS service → {"status":"ok"}. Then: az aks delete.

Two real gotchas along the way: the VM size I'd planned on (Standard_B2s) turned out not to be enabled for this subscription in this region at all — only the newer _v2 generation was. And Azure Container Registry's cloud-build feature is disabled on free-trial subscriptions, so the image had to be built locally and pushed the traditional way instead.

The bigger question, though, was never "can I get a pod running" — it's "should this run continuously." So before building anything I priced it out, honestly, against the alternative I actually have available: adding one more container to the AWS EC2 instance that already hosts my other live projects.

Azure AKS, reliable 24/7Standard_B2s_v2, 2 vCPU/4GiB — not free-tier eligible in this subscription → ~$32–37/month, indefinitely.
Azure AKS, free-tier sizedStandard_B1s, 1 vCPU/1GiB, covered by the 12-month free allowance → $0/month — but AKS's own system pods often eat 300–600MB before your app starts. Fragile, not reliable.
AWS, existing EC2One more Docker container on infrastructure already running and already paid for → ~$0–0.10/month, indefinitely.
What I choseDemo, verify, delete. $0.01–0.02 for the hour it existed. Azure ML stays as the MLOps layer; an always-on backend, if ever needed, goes on the EC2 box.

AWS wins on cost, unambiguously — it's marginal spend on something already running versus provisioning something new. That was never really in question. What was worth doing deliberately was building the AKS path anyway, specifically because Kubernetes is one of the skills this whole project exists to prove, and then being honest about the fact that proving a skill and making a good infrastructure decision aren't always the same move.

Try it

The live dashboard — Windward Fleet — lets you switch between both farms, see the actual-vs-forecast production, the power curves against the Betz limit, and the agent's field report for each, plus a chat panel grounded in the live data.

See it live

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

Open Windward Fleet →