Archived · 2026-03-02 → 2026-08-15

Elec-ForecastDay-ahead electricity demand forecasting, 12 French regions

A self-hosted ML pipeline that ingested, trained, forecast, and monitored French regional electricity demand every 15 minutes for 167 days straight — on Google Cloud free-tier infrastructure. This is its decommissioning record.

1,152Predictions / day
12Regions
15 minData grain
24 hHorizon
167Days live
33Commits

01 · What this was

A production system, not a notebook

Most portfolio ML projects stop at a Jupyter notebook and an accuracy number. This one didn't: it ran as six scheduled Cloud Run jobs against two public APIs, wrote to a partitioned BigQuery warehouse, retrained a LightGBM model daily on a rolling two-year window, logged every run to a self-hosted MLflow server, and served predictions through a public Streamlit dashboard — all provisioned by Terraform, all deployed by Cloud Build on every push to main.

The forecasting target: consumption 24 hours ahead, at 15-minute resolution, for each of France's 12 metropolitan regions — sourced from RTE's public éCO2mix feed and enriched with Open-Meteo weather.

It was archived on 2026-08-15 — not because it broke, but because it had finished doing its job. The write-up below is the honest record: what was built, what it actually achieved in production, and what I'd change if it kept running.

02 · Architecture

Six jobs, one image, three BigQuery datasets

A single Docker image served every batch job; JOB_MODULE picked the entry point. Cloud Scheduler staggered them across the early-morning Paris window so each stage's inputs were ready before the next one ran.

OFFLINE · DAILY RETRAIN SERVING · DAILY FORECAST + MONITORING ODRÉ éCO2mix API Open-Meteo API ingest / reingest */15 min · 01:30 daily elec_raw eco2mix + weather features 01:40 daily elec_features feature store train 01:50 daily model artifact GCS + MLflow UPSERT model.lgb + run log elec_raw lag history Open-Meteo live forecast forecast 03:00 daily elec_ml.predictions 1,152 rows / run metrics */15 +10 min elec_ml.metrics rolling 7d MAE Streamlit dashboard public MLflow UI auth-protected lag history live weather model.lgb actuals UPSERT data flow auxiliary / access-only
Every 15 minutes, ingest pulls new éCO2mix + weather rows into raw BigQuery tables. Overnight, reingest patches late corrections, features materialises the lag/rolling/calendar feature store, and train fits a fresh LightGBM model — logged to MLflow, pushed to GCS. At 03:00 Paris, forecast combines feature-store lags with live Open-Meteo weather to score all 1,152 slot×region predictions. Metrics then compares predictions against actuals every 15 minutes to compute rolling MAE. The dashboard reads the last two BigQuery tables only — it never touches raw data.

03 · Data & features

Twelve features, computed once in SQL

Everything is computed in a single BigQuery round-trip — lags, rolling windows, and calendar flags — then cast to a fixed-category pd.Categorical for region so train and inference always agree on encoding.

regionFixed sorted category list — consistent train/inference encoding
consommation_lag_24hSame time yesterday
consommation_lag_48hSame time 2 days ago — fallback when lag_24h is missing
consommation_lag_168hSame time last week
consommation_rolling_168h7-day rolling mean, leak-guarded (900s gap)
temperature_celsiusRegional centroid, nearest hour
wind_speed_kmhRegional centroid, nearest hour
solar_radiation_wm2Direct radiation, nearest hour
hour_of_day0–23, Europe/Paris local time
day_of_week0 = Monday … 6 = Sunday
is_weekendBoolean
is_public_holiday_frVia the holidays library
rolling_48h_meantestedShorter-term trend — validated, never deployed
rolling_168h_stdtestedWeekly volatility — validated, never deployed

04 · Model benchmark

LightGBM shipped. XGBoost scored better.

Benchmarked with skore's EstimatorReport / ComparisonReport against two naive persistence baselines, on an identical 20,555-row validation split (2026-03-04 → 2026-03-22).

ModelRMSE (MW)Fit timePredict timeStatus
LightGBM0.956397.66.08 s0.60 sDeployed
XGBoost0.966348.216.81 s0.29 sBenchmarked only
Lag 24h baseline0.910570.6<1 ms<1 msReference
Lag 168h baseline0.921534.1<1 ms<1 msReference
Honest note

XGBoost beat the deployed LightGBM model on every accuracy metric — 12% lower RMSE, 1 point higher R² — while also predicting faster. LightGBM was the early choice for simpler categorical handling and was never revisited. If this pipeline kept running, swapping the estimator is the single highest-leverage change available — no new features, no new data, just a different tree library.

A separate experiment isolated the effect of the two features that were built but never shipped:

LightGBM variantRMSE (MW)Feature set
Base0.956397.612 features
Extended0.960381.3+ rolling_48h_mean, rolling_168h_std

05 · Production record

The last numbers before shutdown

Rolling 7-day error, pulled directly from elec_ml.metrics on the day the infrastructure was retired — the true production number, not a validation-set estimate.

RegionMAE (MW)p95 (MW)p99 (MW)
France (total)2,784.38,659.29,907.1
Normandie140.2474.6532.6
Centre-Val de Loire158.6414.7556.9
Bretagne143.4486.8549.1
Pays de la Loire222.9706.2826.6
Occitanie255.2764.7993.2
Provence-Alpes-Côte d'Azur257.6697.7984.2
Grand Est281.6798.91,106.5
Hauts-de-France293.5865.31,008.1
Nouvelle-Aquitaine374.3892.31,112.3
Île-de-France331.11,196.41,367.2
Auvergne-Rhône-Alpes500.41,083.51,457.5
Bourgogne-Franche-Comté502.9932.7993.6

France total MAE of 2,784 MW against an average national demand of ~42,838 MW over the same week — roughly 6.5% relative error, 24 hours ahead, in mid-August.

749,660Eco2mix rows ingested
105,024Predictions written
38Automated tests
7Model versions kept
Streamlit dashboard header showing KPI cards for France total demand, rolling MAE, p95 error and completeness, a French regional choropleth of predicted demand, and a 24-hour actual-vs-predicted time series with a live now line
Dashboard — KPIs, map, time series2026-08-15
Streamlit dashboard lower section showing a horizontal MAE-by-region bar chart, a demand heatmap by region and hour, and a 30-day rolling MAE trend line
Dashboard — MAE by region, heatmap, trendelec-dashboard · Cloud Run
MLflow experiment tracking UI showing a table of 100 matching training runs for the elec-forecast experiment, one per day
MLflow — run history100 runs · elec-forecast
Google Cloud Scheduler console showing six enabled jobs — ingest, reingest, features, train, forecast, metrics — each with its cron frequency and last successful execution
Cloud Scheduler — 6 jobsall “Opération réussie”
Google Cloud Run console listing six jobs — features, forecast, ingest, metrics, reingest, train — each showing a successful last execution in europe-west9
Cloud Run — jobs listeurope-west9
Google Cloud Run job detail for the features job, showing its execution history with one successful run per day going back over two weeks
Cloud Run — execution historyfeatures job · daily 01:40

06 · Retrospective

What I'd do differently

  • Ship XGBoost.Benchmarked better on every metric, in this same repo, and was never promoted to production. The gap between "validated in a notebook" and "deployed" is the most common failure mode in ML systems, and this project is its own example of it.
  • Recalibrate the MAE alert.The production alert fires above 400 MW rolling France-wide MAE — a threshold set early, before demand-scale drift. Live MAE settled around 2,700–2,800 MW, meaning the alert was almost certainly firing continuously rather than flagging real degradation. A threshold needs revisiting once you have enough production history to know what "normal" looks like.
  • Ship the two extended features.rolling_48h_mean and rolling_168h_std were built, tested, and measured a 4% RMSE improvement — then left out of the production feature set because the daily retrain window was never re-validated with them live.
  • Drift detection was deliberately skipped.PSI/KS tests were considered and rejected: with a daily retrain and genuine seasonal variance in electricity demand, a naive drift test would flag the correct seasonal signal as data drift. The right version of this needs a seasonally-aware baseline — not a reason to skip it forever, just not a one-week job.
  • No automated retrain-on-degradation.Rejected on purpose: a runaway retrain loop during a seasonal transition (a heatwave, a cold snap) could chase noise instead of signal. Daily retrain on a fixed schedule was the deliberately boring, safer choice.

07 · Stack

Everything it ran on

Python 3.11 LightGBM XGBoost scikit-learn skore Pydantic v2 Cloud Run Jobs Cloud Run Services BigQuery Cloud Storage Cloud Scheduler Terraform Cloud Build MLflow (self-hosted) Streamlit pytest + ruff
LayerChoiceWhy
ComputeCloud Run Jobs + ServicesScale to zero — no idle cost on a portfolio budget
StorageBigQuery + GCSServerless, generous free tier, partition expiry for automatic retention
OrchestrationCloud SchedulerManaged cron — no Airflow to operate for six jobs
Experiment trackingMLflow, self-hostedPortable, no vendor lock-in; SQLite-on-GCS avoided a Cloud SQL bill
IaCTerraformEvery GCP resource reproducible from source
CI/CDCloud BuildPush to main → build, deploy, smoke-test, all six jobs plus two services

08 · Status

Decommissioned, not deleted

Every job, every Terraform file, the modeling notebook, and 38 tests are still in the repository. The infrastructure that ran it — Cloud Run jobs, the public dashboard, MLflow, BigQuery — was torn down on 2026-08-15 to stop billing on a project that had already answered its own question. The code is the artifact now.

elec-forecast — day-ahead demand forecasting, 12 French regions Adrien Morel · 2026