README.md

WindLens

Day-ahead wind forecast clarity and accuracy

Live app: https://windlens-dayahead.vercel.app

CI

WindLens is decision support for GB day-ahead wind power buying. It pairs the national wind forecast available at decision time (Elexon WINDFOR) with reported wind outturn (Elexon FUELHH) for the January 2026 decision month, measures how far and how often the forecast misses, and translates the results into plain language for a buying team.

Decision viewAccuracy view
Decision viewAccuracy view
Buying notesMobile width

What it does

  1. See the decision clearly. Pick a delivery day and a decision cutoff; WindLens shows the exact forecast edition that was available at that moment, when it was published, and how every earlier edition had evolved. No actuals leak into this view.
  2. Quantify the error. Compare forecast against outturn over the whole month: MAE, RMSE, bias, percentiles, error by hour of day, by lead time, and the full distribution, plus a one-sided overforecast buffer you can reason about when buying.
  3. Read it in plain language. A buying notes view turns the metrics into sentences, with the limitations stated honestly.

Quick start

Docker (recommended):

bash
1docker compose up --build

Without Docker:

bash
1# Backend (Python 3.12)
2python -m venv .venv
3.venv/bin/pip install -r backend/requirements-dev.txt
4.venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000
5
6# Frontend (Node 22), in a second terminal
7cd frontend
8npm ci
9npm run dev -- --host 0.0.0.0 # port 3000, proxies /api and /health to 8000

Tests, lint, and maintenance:

bash
1.venv/bin/python -m pytest # backend tests
2.venv/bin/coverage run -m pytest # then:
3.venv/bin/coverage report --include=backend/metrics.py # metrics module coverage
4.venv/bin/ruff check backend tests # backend lint
5cd frontend && npm test && npm run lint # frontend tests and lint
6.venv/bin/python -m backend.ingest # refresh snapshot (contacts Elexon, maintenance only)

Repository guide

1tcnswzd/
2 api/index.py Vercel entrypoint; imports the same FastAPI app object
3 backend/
4 app.py FastAPI route handlers
5 metrics.py pure selection, alignment, and metric functions
6 domain.py timestamp parsing and record validation
7 models.py pydantic response models
8 store.py snapshot loading, resolved from the file location
9 ingest.py Elexon ingestion (maintenance command, not request path)
10 Dockerfile backend runtime image (python 3.12 slim, non root)
11 requirements.txt Docker runtime dependencies (includes uvicorn)
12 requirements-dev.txt development and CI dependencies (coverage, httpx, pytest, ruff)
13 data/january_2026.json committed snapshot: 1,488 actual rows, 17,864 forecast vintages
14 frontend/
15 src/api/client.ts typed API client; the only place fetch is called
16 src/lib/ formatting helpers, derived values, buying notes builder
17 src/components/ shared UI components (charts, controls, states, tables)
18 src/views/ Decision, Accuracy, and Buying notes views
19 src/__tests__/ Vitest and Testing Library tests
20 Dockerfile multi stage build; nginx serves static and proxies /api
21 nginx.conf same origin proxy for /api and /health
22 package.json scripts and dependencies
23 docs/
24 DESIGN.md data research, contract, and bias analysis
25 archive/HANDOFF.md phase by phase build history
26 samples/ small raw Elexon API samples
27 screenshots/ UI captures used above
28 scripts/ reproducible bias audit scripts
29 tests/ backend pytest suite
30 docker-compose.yml backend on 8000, frontend on 3000
31 vercel.json Vercel build settings and rewrites
32 requirements.txt Vercel function runtime dependencies (no uvicorn)

Architecture

1Elexon BMRS streams (FUELHH actuals, WINDFOR forecasts)
2 | python -m backend.ingest (one time maintenance command)
3 v
4backend/data/january_2026.json committed, versioned snapshot
5 |
6 v
7FastAPI app (backend/app.py) ---- pure functions in backend/metrics.py
8 | selection at cutoff, hourly alignment, metrics
9 | /api/... and /health
10 v
11React + Vite static build
12 Decision view | Accuracy view | Buying notes
13
14Deployment split:
15 Docker (local, review, CI): uvicorn on 8000 + nginx on 3000 proxying /api and /health
16 Vercel (production): static frontend build + one Python function from the
17 same FastAPI app object, same origin, no CORS

Two requirements files, on purpose:

  • Root requirements.txt lists fastapi and pydantic only. Vercel's Python runtime serves the ASGI app itself, so uvicorn is not deployed.
  • backend/requirements.txt lists fastapi, starlette, and uvicorn for the Docker runtime image, which runs its own ASGI server. Development dependencies live in backend/requirements-dev.txt.

Key decisions and assumptions

DecisionChoiceWhy
ScopeDelivery days Jan 2 through Feb 1, 2026"Decisions made in January" means the D-1 decision date is in January. Covers all 31 decision days with complete actuals.
Decision cutoff09:00 Europe/London on D-1, configurable per requestAssumption: 20 minutes before the EPEX hourly gate (09:20 GMT) and 50 minutes before N2EX (09:50). Not a client commitment.
Forecast selectionLatest publishTime at or before the cutoffReconstructs the information actually available at decision time.
Hourly alignmentForecast at UTC T vs mean of actuals at T and T+30 minutesTreats the target as the hour beginning at T. Missing either half hour drops the target.
Error signForecast minus actualPositive means the forecast overreported.
BufferP90 and P95 of max(error, 0), in MW and percent of forecastHistorical overforecast reference, not a purchase recommendation.
Metrics stackPython standard library (math, statistics)Small, auditable, no analytics dependencies.

Findings: January 2026

Across the 744 scored decision hours (09:00 cutoff): mean actual 11,283.6 MW, MAE 2,038.91 MW, RMSE 2,504.76 MW, signed bias +1,747.89 MW, relative bias +15.49%, nMAE 18.07%.

  • The forecast averaged about 1.75 GW above reported output. The gap is already present at short lead (+1,454.93 MW, 12.76% of mean actual, at 0 to 4 h) and grows slowly with lead, about 9.8 MW per additional hour, to +1,942.49 MW (17.55%) at 44 to 48 h.
  • 27 of 31 delivery days have a positive mean error; the P90 and P95 overforecast buffers are 4,097.9 MW and 5,283.35 MW (29.8% and 41.9% of forecast).
  • This is evidence, not proof, of a cause. These datasets do not report curtailment or unconstrained wind availability, so the gap cannot be attributed. It is one month, in sample. A temporal alignment sensitivity (MAE minimum at a -2 h actual window shift, worth only 4.1% MAE) remains an open validation item with a data owner.

Full analysis: docs/DESIGN.md. Reproduce the tables with PYTHONPATH=. .venv/bin/python scripts/bias_diagnostic.py.

Metrics

MetricDefinition
Errorforecast MW minus actual MW (positive = overforecast)
MAEmean absolute error, MW
RMSEroot mean square error, MW
Biasmean signed error, MW
nMAEMAE as percent of mean actual
Relative biasbias as percent of mean actual
MAPEmean of percent errors over nonzero actuals only (exclusions reported)
Percentilessigned P5/P10/P50/P90/P95 and absolute P50/P90/P95, linear interpolation
BufferP90/P95 of max(error, 0) in MW and as percent of forecast

API

Base path /api in production and Docker. Dates are YYYY-MM-DD; cutoff is HH:MM Europe/London. Errors return {"detail": "..."} with HTTP 422 or 503.

EndpointParametersReturns
GET /healthnonesnapshot status and row counts
GET /api/forecast/latestdelivery_date, cutoffactual free forecast at the cutoff per delivery hour
GET /api/actual-vs-forecaststart, end, cutoffchronological paired series with coverage counts
GET /api/accuracy/summarystart, end, cutoffall headline metrics, percentiles, buffers
GET /api/accuracy/by-hourstart, end, cutoffMAE and bias by local delivery clock hour
GET /api/accuracy/by-lead-timestart, endMAE and bias per one hour lead bucket, all January vintages
GET /api/accuracy/distributionstart, end, cutoffsigned and absolute error arrays
GET /api/accuracy/cutoff-sensitivitystart, end, optional cutoffmetrics for 08:00, 09:00, 09:20 plus supplied
GET /api/revisionsdelivery_date, cutoffevery published edition for a day, selection marked

Testing and CI

  • Backend: 20 pytest tests, including metric edge cases and the golden worked example (Jan 2, 00:00 UTC: forecast 18,781 MW vs actual mean 15,366.5 MW, error +3,414.5 MW). Metrics module coverage: 100%.
  • Frontend: 29 Vitest and Testing Library tests covering formatting, derivations, the buying notes builder (including a negative bias case), the cutoff control, and loading, error, and empty states. ESLint and Prettier configured.
  • CI runs backend, frontend, and docker jobs on every push and pull request; the docker job builds both images, waits for healthy containers, and asserts the smoke values above.

Limitations and next steps

  • One month of data, fully in sample. Results describe January 2026, not the future.
  • FUELHH reports generation by fuel type; WINDFOR covers farms visible to the system operator. The populations may differ, which caps how tightly error can be interpreted.
  • No curtailment or unconstrained availability field exists in these datasets; the positive bias is measured, not explained.
  • The WINDFOR hourly interval boundary is unconfirmed with a data owner (the empirical -2 h alignment minimum remains open).
  • The 09:00 cutoff is an assumption based on auction gate times.
  • Next steps: more months, an availability feed, bias adjusted forecast display by default, and per region breakdowns if Elexon exposes them.

AI tools disclosure

Claude (chat) and an AI coding agent were used for domain research, design, implementation, tests, documentation, and deployment tooling for this project. The author reviewed, verified, and takes responsibility for all of it: every metric in this README is reproducible from the committed snapshot with the scripts in scripts/, and every claim is traceable to docs/DESIGN.md.