Probabilistic day-ahead solar power forecasting for Germany, built for the forecasting challenge at energy-arena.org.
Developed as part of a seminar at Karlsruher Institut für Technologie (KIT), Institut für Industriebetriebslehre und Industrielle Produktion (IIP).
The model does not predict raw power directly. It predicts the Clear-Sky
Index k_PV — the ratio of actual power to the clear-sky power a clear day
would yield:
k_PV = P_actual / P_clearsky
A LightGBM model predicts k_PV, which is then multiplied back by a pvlib
clear-sky power curve and the installed capacity to recover MW. Factoring out
the deterministic solar geometry first removes most seasonal bias and leaves the
ML model to learn only the residual (cloud attenuation, temperature effects).
The model is applied across all 50 Open-Meteo NWP ensemble members to
produce 50 calibrated MW trajectories.
Open-Meteo NWP ensemble — 50 members
(irradiance, cloud cover, temperature)
│
▼
features (pvlib clear-sky power P_clearsky, k_PV, time, lags)
│
▼
LightGBM ──► predicted k_PV (per member)
│
▼
power = k_PV × P_clearsky × installed capacity (back to MW)
│
▼
EMOS calibration (trunc-normal + ECC)
│
▼
50 calibrated MW trajectories (hourly, day-ahead)
Requires Python ≥ 3.12.
git clone <this-repo> solar-forecast
cd solar-forecast
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]" # library + dev tools (pytest, jupyter, matplotlib)Training targets and evaluation actuals come from the ENTSO-E Transparency Platform, which needs a free API token.
cp .env.example .env
# edit .env and set ENTSOE_API_KEYRegister at transparency.entsoe.eu →
My Account → Security Token. The token is read from the ENTSOE_API_KEY
environment variable.
This .env is required in the solar-forecast repo itself — including when
the forecast is driven from the energy-arena submission repo (see §7). The
submission repo has its own separate configuration; it does not replace this
one. Keep an .env here as well.
The Open-Meteo NWP and IFS-historical APIs need no key on the free tier.
Model, physics, and geometry settings live in config.py:
| Setting | Meaning |
|---|---|
LAT, LON, ALTITUDE |
Single fallback grid point (used when CENTROIDS is None) |
SURFACE_TILT, SURFACE_AZIMUTH |
Panel geometry for the pvlib POA calculation |
CAPACITY_MW |
Total installed DE solar capacity — converts k_PV to MW (verify before use) |
CENTROIDS |
Capacity-weighted grid points for spatial aggregation (see §6) |
LGBM_PARAMS |
LightGBM hyperparameters |
FORECAST_DAYS |
Forecast horizon in days |
Arena-specific runtime values (area, challenge_id, api_base, ENTSO-E key
for the live submission) are not set here — they are supplied by the
submission repo and passed into pipeline.run(). See §7.
The modules run in a fixed order — each stage feeds the next. All commands are run from the repo root with the venv active.
Running from scratch (another machine, no shipped artifacts). Follow these steps in order; each links to its section below:
- Centroids (Stage 0) — generate the capacity-weighted grid points and
paste them into
config.CENTROIDS. Do this first: training aggregates IFS across exactly these points. - Train + fit EMOS (Stage A) — one command,
python scripts/retrain_with_ifs.py, retrains thek_PVmodel on IFS historical and refits EMOS →models/lgbm_ci.pkl+models/calibration.json. - Daily forecast (Stage B) — verify a single run produces trajectories.
- Automate (§7) — wire the daily run into the energy-arena submission repo (cron on a VM).
Derive the capacity-weighted grid points the forecast aggregates over
(P_country = Σᵢ wᵢ · Pᵢ) from Marktstammdatenregister (MaStR) installed-capacity
data, then paste the printed list into config.CENTROIDS. Do this before
training — Stage A aggregates IFS across exactly these points. See
§6 for the data sources and details.
python -m solar_forecast.capacity --k 15 # k-means centroids
python -m solar_forecast.capacity --k 15 --force-download # re-download MaStR first
# → copy the printed list into config.CENTROIDSSkip this to keep the committed default config.CENTROIDS, or set
config.CENTROIDS = None to fall back to the single LAT/LON point.
scripts/retrain_with_ifs.py runs the whole training + calibration pipeline in
one command, anchored on ECMWF IFS-historical inputs (Open-Meteo
historical-forecast-api) and ENTSO-E / SMARD solar actuals, with a sensible
train/backtest split. This is the recommended setup path:
python scripts/retrain_with_ifs.py
# Step 1 train LightGBM (2024-02-03 → backtest-start) → models/lgbm_ci.pkl
# Step 2 backtest (backtest-start → backtest-end)
# Step 3 refit EMOS (fit_emos on the held-out window) → models/calibration.json
# Step 4 print raw vs. calibrated backtest metricsFlags: --train-start (default 2024-02-03, earliest IFS date),
--backtest-start (default 2025-09-01), --backtest-end (default
2026-05-31), --emos-only (skip the LightGBM retrain and only refit EMOS on
the backtest window — use this after tweaking calibration).
Training aggregates IFS across config.CENTROIDS, so those must be set first
(step 1 of the checklist above).
Lower-level entry points (finer control)
# train only (add --quantile for the 100 quantile k_PV models → models/lgbm_quantile.pkl)
python -m solar_forecast.train --start 2024-02-03 --end 2025-09-01
# useful flags: --area {DE,AT}, --capacity-mw, --no-cv, --augments N (default 4)
# backtest + fit EMOS only
python scripts/backtest.py --start 2025-09-01 --end 2026-05-31 --mode ensemble \
--save data/processed/backtest.parquetFetches the live 50-member NWP ensemble, runs the k_PV ensemble, applies
calibration, and writes data/forecasts/<date>.parquet (timesteps × 50
scenarios, in MW).
python pipeline.py --date 2026-07-16 # target date (default: tomorrow)
python pipeline.py --date 2026-07-16 --quantile # use the 100 quantile models
python pipeline.py --date 2026-07-16 --no-save # print only, don't writescripts/run_daily.sh wraps this for a local cron job (targets tomorrow).
Stage A already fits the calibration; this explains what it does. The pipeline
calibrates via EMOS (apply_emos): a truncated-normal distribution
μ = a + b·ens_mean, σ² = c + d·ens_var fitted by CRPS minimisation, with the
NWP member ranks mapped through it by Ensemble Copula Coupling (ECC) to keep each
trajectory temporally coherent. The fitted a, b, c, d live in
models/calibration.json, which pipeline.py loads and applies automatically.
To refit calibration only (e.g. after a new backtest window) without retraining
LightGBM, use python scripts/retrain_with_ifs.py --emos-only.
A simpler bias + spread correction (single spread factor, no per-timestep
σ) is also available via the calibrate CLI. apply_emos falls back to it
automatically when calibration.json carries no emos key:
python -m solar_forecast.calibrate --forecast data/processed/backtest.parquet \
--start 2024-01-01 --end 2024-06-30The evaluation snapshot used for the seminar paper is committed under
data/eval_cache/ (Challenge 16, DE_LU, window 2026-06-07 … 2026-07-07, seven
participants). No network access or API key is needed to regenerate the metric
tables from it:
python scripts/eval_probabilistic_metrics.py
# → prints the CRPS / WIS / skill table from the frozen snapshotAdd --out results.csv to save the table. The frozen output tables the paper
cites live in paper/results_ch16_metrics.csv
and paper/results_ch16_window.csv.
To re-fetch a fresh snapshot from the arena API instead of using the cache
(needs arena API access), use scripts/fetch_arena_scores.py
(--challenge-id, --start, --end, --participants).
python scripts/plot_pit_daytime.py # PIT calibration histogram
python scripts/plot_underforecast_fan.py
python scripts/plot_forecast.pyExploratory analysis lives in notebooks/: data exploration,
feature engineering, and calibration analysis. Launch with jupyter lab.
pytest tests/Covers clearsky, features, fetch_actuals, and fetch_nwp.
Instead of one grid point, the forecast aggregates over capacity-weighted
centroids P_country = Σᵢ wᵢ · Pᵢ. Installed-capacity data is sourced via the
open-mastr package from the Marktstammdatenregister (MaStR); missing GPS
coordinates are filled from a PLZ→coordinate map sourced from
WZBSocialScienceCenter.
Regenerate the centroids and paste the printed list into config.CENTROIDS:
python -m solar_forecast.capacity --k 15 # k-means centroids
python -m solar_forecast.capacity --k 15 --force-download # re-download MaStRSet config.CENTROIDS = None to fall back to the single LAT/LON point.
This repo is platform-agnostic — it returns the trajectories as a DataFrame
and knows nothing about the competition payload format. The integration lives in
custom_model.py, which adapts the trajectories into the
arena submission payload and passes the arena runtime config (area, challenge id,
API base, ENTSO-E key) into pipeline.run().
Setup:
- Clone the energy-arena
starter repo
side by side with this repo (same parent directory) —
custom_model.pyresolves the sibling../solar-forecastonsys.path. - Set up the starter repo per its docs (its own API credentials and config).
- Copy
custom_model.pyfrom this repo into the starter repo. It is the bridge between the two repos and must live there to run. - Ensure this repo still has its own
.env(see §2) — the pipeline reads it. - Automate on a VM (e.g. cron) as described in the starter repo.
To enable proper EMOS calibration later, archive the live NWP ensemble once per
day at submission time with run_collect_ensemble.sh, optimally with a cronjob
(Open-Meteo only serves the current run, so the archive can only grow forward).
solar_forecast/
├── clearsky.py # pvlib Ineichen clear-sky POA power
├── fetch_nwp.py # Open-Meteo 50-member ensemble (cached)
├── fetch_actuals.py # IFS historical + ENTSO-E solar actuals
├── features.py # k_PV, cyclic encodings, solar geometry, lags
├── train.py # LightGBM + walk-forward CV (CLI)
├── ensemble.py # apply model across 50 members → MW trajectories
├── calibrate.py # EMOS (trunc-normal + ECC) + bias/spread fallback (CLI)
├── evaluate.py # CRPS, skill score, PIT (CLI)
├── capacity.py # MaStR k-means centroids (CLI)
└── collect_ensemble_forecasts.py # daily NWP archiver (CLI)
config.py # coordinates, capacity, centroids, hyperparameters
pipeline.py # daily run: fetch → forecast → calibrate → save (CLI)
custom_model.py # adapter — copy into the energy-arena starter repo
scripts/ # backtest, evaluation, plotting, arena fetch
data/eval_cache/ # frozen arena snapshot for reproducing paper results
models/ # trained artifacts (lgbm_ci.pkl, lgbm_quantile.pkl)
| Data | Source | Use |
|---|---|---|
| NWP ensemble (50 members) | Open-Meteo Ensemble API | Inference inputs |
| Historical NWP | IFS historical via Open-Meteo (ecmwf_ifs025) | Training inputs |
| Generation actuals | ENTSO-E Transparency Platform | Training targets |
| Installed capacity | Marktstammdatenregister (via open-mastr) | Spatial weights |
| Clear-sky model | pvlib (Ineichen) | Physics baseline |
See LICENSE.