VoltCast:24-Hour US Electricity Demand Forecasting
Serverless MLOps system forecasting the next 24 hours of electricity demand for 4 US grid regions (CAL, ERCOT, PJM, MISO) with Transformer and LSTM models written from scratch in PyTorch. No always-on backend: scheduled GitHub Actions ingest, clean and validate, train, evaluate, register, forecast, and drift-gate retraining; a Next.js dashboard reads forecasts directly from S3.
Core Impact
“From-scratch PyTorch models that beat both naive baselines in all 4 regions (best model 16% to 39% better than the best baseline, 1,236 MW MAE in California), with the champion chosen on a held-out validation year and kept fresh by a performance-based, drift-gated, serverless GitHub Actions pipeline.”

1,236 MW
CAL test MAE, 3.8% of demand
16% to 39%
Best model vs best naive baseline
$0
Compute cost, serverless CI
The Problem
Grid operators must match electricity supply to demand every hour: too little means blackouts, too much means wasted money. Forecasting demand 24 hours ahead is the same problem real ISOs solve, but most demos stop at a model in a notebook. I wanted the full production loop, ingest to drift-gated retraining, with no always-on server to pay for and every layer built explicitly so the engineering is visible.
The Approach
Why models from scratch
Every layer is written by hand with a raw AdamW training loop, cosine annealing, gradient clipping, and early stopping. An LSTM and two naive baselines (copy yesterday, copy last week) run alongside the Transformer, so the improvement is measured, not assumed. The honest result is that the Transformer and LSTM are close, and the registry picks whichever scores better on the validation year.
Why one-shot 168 to 24
The model sees 168 real hours (one week) and outputs all 24 future hours at once. No recursive feeding of predictions back as inputs, which compounds error step by step. One forward pass, full day forecast.
Why performance-based drift
A statistical test on the inputs only says the data changed, not that the forecasts got worse. `drift.py` replays the champion's last 60 days against what happened and compares it with copy-yesterday, because some seasons are harder for every forecaster. Retraining fires on drift OR a 30-day age cap, so slow shifts are caught too.
Why serverless on Actions
Scheduled GitHub Actions run a light daily forecast and weekly per-region retrains on free CPU runners; models live in a DagsHub registry, forecasts in S3, and the Next.js frontend reads S3 JSON through a server route that keeps AWS keys off the browser. No backend stays running, so the whole system costs roughly nothing to operate.
Technical Deep Dive
Built a TemporalTransformer layer by layer in PyTorch (no HuggingFace, no `Trainer`): linear projection, sin/cos positional encoding, 2 encoder layers (nhead=4, dim_feedforward=256), one-shot 168h-in to 24h-out head over 10 features. On an untouched test year the best model beats the best naive baseline by 16% (TEX) to 39% (MISO); California Transformer MAE is 1,236 MW (3.8% of demand) vs 1,642 MW for copy-yesterday.
Compared four forecasters in every region (copy yesterday, copy last week, a from-scratch LSTM, the Transformer). Both neural models beat both baselines everywhere; the Transformer leads the LSTM by only 0.5% to 6% (a tie in PJM), so the registry picks the champion on a separate validation year, and the LSTM won in PJM and MISO.
Engineered a Champion/Challenger registry on DagsHub MLflow: `registry.py` scores both models on the validation year (never the test year), saves each scaler with its model, and promotes a challenger only if it beats the live champion by >1% after the champion is re-scored on the same data.
Designed performance-based drift detection: `drift.py` replays the champion's last 60 days of forecasts against what actually happened and judges it relative to copy-yesterday, comparing that ratio to its test-time ratio (exit code 10 = drift, any other failure is an error). A weekly per-region retrain fires on drift OR a 30-day-old champion.
Ran the whole loop serverless on GitHub Actions: a daily forecast job that pulls only the last 3 weeks of data (no training), a weekly retrain matrix with one job per region, and a manual full rebuild, all on free public-repo CPU runners at ~$0. Raw data is re-pulled from EIA each run (rolling 5-year window), nothing stored stale.
Enforced leak-free time-series discipline: 5 years split chronologically into 3 train / 1 validation / 1 test, never shuffled, scalers fit on training years only. `validation.py` blanks impossible values and glitches, fills gaps (short ones by straight line, long ones from the same hour a week earlier), and a Pandera contract confirms one clean value per hour before any row reaches training.
Systems Analysis Concluded