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:
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 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.
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
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:
- A second RAG stack. LlamaIndex, deliberately a different framework over a different corpus — structured turbine spec metadata rather than incident narratives. Asked it Kelmarsh 3's hub height: 68.5m, matching the source data exactly.
- Real multimodal. Sourced one openly-licensed inspection photo (Wikimedia Commons, CC BY-SA 3.0) and ran it through Amazon Nova Lite's vision input via the Bedrock Converse API. It correctly declined to invent damage on a photo that isn't a damage close-up, rather than hallucinating a finding to seem useful.
- Langfuse tracing, wired into every agent run — a real no-op today pending a free Langfuse signup, but the integration code is live, not a stub.
- DynamoDB, actually written to now: every dashboard export persists a real session record (farm, timestamp, capacity factor, recommendation) — verified with a live table scan.
- A chat panel on the dashboard, grounded in the live data, powered by Claude's own on-page assistant capability rather than a custom backend — not a style choice: a published page's sandbox can't call an external API at all, so this was the only way to get an interactive AI feature working inside it.
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 pods → windward-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.
Standard_B2s_v2, 2 vCPU/4GiB — not free-tier eligible in this subscription → ~$32–37/month, indefinitely.Standard_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 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.