Source: github.com/brunolimaam/pv-forecast-de

Day-ahead PV forecast for Germany

Given tomorrow's weather, predict German national solar generation hour by hour — scored against two naive baselines and the grid operators' own day-ahead forecast.

  • Python · scikit-learn · pvlib · pandas
  • Data: SMARD (Bundesnetzagentur) and Open-Meteo / ERA5
  • Trained on 2023, tested on 2024
Contents
  1. Data and features
  2. Split
  3. Baselines
  4. The model
  5. Reading the table
  6. The caveat that qualifies all of this
  7. Where the error comes from
  8. What the drift costs
  9. Diagnostics
  10. Does it track a real day?
  11. Error by hour of day
  12. Error through the year
  13. The days it got badly wrong
  14. Uncertainty: P10 / P50 / P90
  15. What the model actually uses
  16. Where this leaves things

At 10:00 today, predict German national PV generation for every hour of tomorrow. Horizon 24 h, hourly resolution, with P10/P50/P90 quantiles.

Three things are compared on the same 2024 hours:

what it knows
persistence baselines yesterday's generation, and the sun's position
this model ERA5 weather + calendar + lagged generation (gradient boosting)
TSO forecast the four German TSOs' own day-ahead submission — the target to beat

Result up front. On daylight hours the model scores 2492 MW MAE against 3755 for clear-sky persistence and 1214 for the TSOs. So it takes a third of the error out of the naive forecast and is still roughly twice the TSOs'. A large part of that remaining gap turns out not to be meteorology — it is a systematic bias, traced in Where the error comes from.

Read the caveat before quoting any number here: the model sees reanalysis weather, the TSOs saw a real forecast.

In [1]:
import os
import sys

# notebooks/ is the documented home; run from the project root either way
if os.path.basename(os.getcwd()) == "notebooks":
    os.chdir("..")
sys.path.insert(0, os.getcwd())

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.inspection import permutation_importance
from sklearn.metrics import mean_absolute_error, mean_pinball_loss

from src.build_baselines import all_baselines
from src.build_features import HORIZON_H, feature_columns
from src.evaluate import CAPACITY_MW, score_table
from src.fetch import SITES

SEED = 0            # fixed everywhere so the README numbers reproduce
TRAIN, TEST = "2023", "2024"

# rows the lag features cannot supply; never dropna on the feature columns
# (k_cs_lag24 is NaN at night by design and HistGradientBoosting handles NaN)
NEED = ["pv_actual", "pv_lag24", "pv_lag168", "pv_roll7d"]

pd.set_option("display.width", 120, "display.max_columns", 30)
plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})
In [2]:
feat = pd.read_parquet("data/cached/features.parquet")

print(f"{feat.shape[0]:,} hours x {feat.shape[1]} columns")
print(f"{feat.index.min()}  ->  {feat.index.max()}  ({feat.index.tz})")
print(f"horizon {HORIZON_H} h, nMAE normalised by {CAPACITY_MW:,} MW")
17,545 hours x 88 columns
2023-01-01 00:00:00+00:00  ->  2025-01-01 00:00:00+00:00  (UTC)
horizon 24 h, nMAE normalised by 47,000 MW

Data and features

SMARD (Bundesnetzagentur), hourly: pv_actual (realised PV, MW) and pv_forecast (the TSOs' day-ahead submission, filed by 18:00 for the whole next day). Open-Meteo / ERA5 for 8 cities — hamburg, berlin, leipzig, koeln, frankfurt, nuernberg, stuttgart, muenchen — as per-city columns plus _de national means.

Derived in src/build_features.py: clear-sky GHI and solar zenith per city (pvlib), a cloud index (observed / clear-sky radiation), cyclic calendar encodings, and lagged generation.

Two rules govern what the model may read.

  1. No leakage. Anything derived from an observation is shifted by at least HORIZON_H = 24 h. add_lags raises if a lag is shorter.
  2. Forbidden as inputs: pv_actual (the target), pv_forecast (the benchmark), k_cs (computed from the target at the same hour), is_day, load (measured, not known ahead). The lagged forms k_cs_lag24 and load_lag24 are fine.

feature_columns() enforces rule 2; the assertion below is the receipt.

In [3]:
cols = feature_columns(feat)
FORBIDDEN = {"pv_actual", "pv_forecast", "k_cs", "is_day", "load"}
assert not (FORBIDDEN & set(cols)), FORBIDDEN & set(cols)

per_city = [c for c in cols if any(c.endswith(f"_{s}") for s in SITES)]
national = [c for c in cols if c not in per_city]

print(f"{len(cols)} features = {len(national)} national/calendar "
      f"+ {len(per_city)} per-city ({len(per_city) // len(SITES)} x {len(SITES)} cities)")
print(f"\nnational and calendar:\n  {national}")
print(f"\nper city ({', '.join(SITES)}):")
print("  " + str(sorted({c.rsplit('_', 1)[0] for c in per_city})))
print(f"\nheld out of the model: {sorted(FORBIDDEN)}")
84 features = 20 national/calendar + 64 per-city (8 x 8 cities)

national and calendar:
  ['shortwave_radiation_de', 'direct_radiation_de', 'diffuse_radiation_de', 'temperature_2m_de', 'cloud_cover_de', 'ghi_cs', 'solar_zenith', 'hour', 'doy', 'month', 'dayofweek', 'hour_sin', 'hour_cos', 'doy_sin', 'doy_cos', 'pv_lag24', 'pv_lag48', 'pv_lag168', 'k_cs_lag24', 'pv_roll7d']

per city (hamburg, berlin, leipzig, koeln, frankfurt, nuernberg, stuttgart, muenchen):
  ['cloud_cover', 'cloud_idx', 'diffuse_radiation', 'direct_radiation', 'ghi_cs', 'shortwave_radiation', 'solar_zenith', 'temperature_2m']

held out of the model: ['is_day', 'k_cs', 'load', 'pv_actual', 'pv_forecast']

Split

Time-ordered, never random: a random split would let the model see hours from either side of the hours it is scored on, and neighbouring hours are close to duplicates. Train on 2023, test on 2024 — one full year each, so every season appears in both.

In [4]:
train = feat.loc[TRAIN].dropna(subset=NEED)
test = feat.loc[TEST].dropna(subset=NEED)

for name, part in [("train", train), ("test", test)]:
    print(f"{name:5s} {len(part):5,} h   {part.index.min().date()} -> "
          f"{part.index.max().date()}   {part['is_day'].sum():,} daylight h")
assert train.index.max() < test.index.min(), "train must end before test starts"
train 8,569 h   2023-01-08 -> 2023-12-31   3,973 daylight h
test  8,784 h   2024-01-01 -> 2024-12-31   4,037 daylight h

Baselines

If the model cannot beat these, it is not worth having.

  • persistence — tomorrow at this hour equals today at this hour.
  • clearsky_persistence — yesterday's cloudiness applied to today's sun position, so the astronomical part of the change is handled.
  • cs_persistence_daily — yesterday's generation scaled by the ratio of daily clear-sky totals.
  • tso_forecast — the TSOs' own day-ahead forecast. Not a baseline so much as the standard to be measured against.
In [5]:
preds = all_baselines(feat).loc[test.index]
print(score_table(test, preds))
                         MAE    RMSE  nMAE_%  MAE_day  RMSE_day  nMAE_day_%
method                                                                     
persistence           1783.4  3599.4     3.8   3757.8    5290.9         8.0
clearsky_persistence  1782.2  3599.5     3.8   3755.3    5291.1         8.0
cs_persistence_daily  1783.0  3600.0     3.8   3757.0    5291.8         8.0
tso_forecast           568.8  1164.3     1.2   1213.7    1717.0         2.6

The model

HistGradientBoostingRegressor at library defaults, one fit, no tuning.

Gradient boosting builds decision trees in sequence, each one predicting the errors left over by those before it, so a few hundred deliberately weak trees sum to a strong predictor. The algorithm is Friedman's, described in The Elements of Statistical Learning, ch. 10 with some tweaks.

It handles NaN natively, which is why k_cs_lag24 can stay missing at night by design rather than being imputed. Predictions are clipped at zero.

In [6]:
gbr = HistGradientBoostingRegressor(random_state=SEED)
gbr.fit(train[cols], train["pv_actual"])

preds["model"] = gbr.predict(test[cols]).clip(0)
print(gbr)
HistGradientBoostingRegressor(random_state=0)
In [7]:
# overfit check: train and test error side by side
for name, part in [("train", train), ("test", test)]:
    p = pd.Series(gbr.predict(part[cols]), index=part.index).clip(0)
    day = part["is_day"]
    print(f"{name:5s} MAE {mean_absolute_error(part['pv_actual'], p):6.0f} MW   "
          f"MAE_day {mean_absolute_error(part['pv_actual'][day], p[day]):6.0f} MW")
train MAE    289 MW   MAE_day    583 MW
test  MAE   1166 MW   MAE_day   2487 MW

Train error is a quarter of test error. Some of that is genuine overfitting, but most of it is a different problem, isolated two sections down.

In [8]:
tab = score_table(test, preds)

cs = tab.loc["clearsky_persistence", "MAE_day"]
tso = tab.loc["tso_forecast", "MAE_day"]
tab["skill_vs_cs"] = (1 - tab["MAE_day"] / cs).round(3)
tab["gap_to_tso_%"] = (100 * (tab["MAE_day"] / tso - 1)).round(1)

print(f"{TEST} test set, {tab.attrs['n_hours']:,} hours scored "
      f"(identical rows for every method)\n")
print(tab)
2024 test set, 8,776 hours scored (identical rows for every method)

                         MAE    RMSE  nMAE_%  MAE_day  RMSE_day  nMAE_day_%  skill_vs_cs  gap_to_tso_%
method                                                                                                
persistence           1783.4  3599.4     3.8   3757.8    5290.9         8.0       -0.001         209.6
clearsky_persistence  1782.2  3599.5     3.8   3755.3    5291.1         8.0        0.000         209.4
cs_persistence_daily  1783.0  3600.0     3.8   3757.0    5291.8         8.0       -0.000         209.5
tso_forecast           568.8  1164.3     1.2   1213.7    1717.0         2.6        0.677           0.0
model                 1166.3  2346.3     2.5   2491.7    3459.6         5.3        0.336         105.3

Reading the table

Daylight is the honest number. Night hours are trivially zero for every method and dilute the all-hours columns by more than half.

Skill vs clear-sky persistence 0.34 — the model removes a third of the naive forecast's error. Gap to TSO +105% — it is still about twice their error.

The three persistence baselines are indistinguishable. Expected, not a bug: they all carry the same cloud error, which dominates, and the clear-sky correction only fixes the smaller astronomical component.

The caveat that qualifies all of this

The model is trained and tested on ERA5 reanalysis — a reconstruction of what the weather actually did, assembled after the fact. The TSO forecast had to use a real NWP forecast made before the fact.

So this model sees near-perfect weather and its scores are optimistic. The comparison bounds what is achievable given good weather input; it does not show that the model could replace an operational forecast. Swapping ERA5 for real IFS/AIFS forecasts via the Open-Meteo ECMWF endpoint, and measuring the drop, is the obvious next experiment.

Where the error comes from

Before blaming the weather, split the error into the part that is a constant offset (bias) and the part that is scatter around it. A forecast can be wrong because it does not know what the sky will do, or because it is forecasting the wrong power plant.

In [9]:
err_day = {}
for name in ["clearsky_persistence", "tso_forecast", "model"]:
    e = (preds[name] - test["pv_actual"])[test["is_day"]].dropna()
    err_day[name] = {"bias": e.mean(), "MAE": e.abs().mean(),
                     "dispersion (MAE of e - bias)": (e - e.mean()).abs().mean()}

print(f"daylight hours only, mean actual "
      f"{test['pv_actual'][test['is_day']].mean():.0f} MW\n")
print(pd.DataFrame(err_day).T.round(0))
daylight hours only, mean actual 15516 MW

                        bias     MAE  dispersion (MAE of e - bias)
clearsky_persistence   -11.0  3755.0                        3755.0
tso_forecast           222.0  1212.0                        1206.0
model                -2012.0  2487.0                        2259.0

The model's mean daylight error is -2012 MW — 81% of its MAE in magnitude, so the error is overwhelmingly one-signed. The TSO forecast is essentially unbiased (+222 MW, 18% of its MAE).

Bias does not simply subtract from MAE, so read the third column carefully: strip the offset and the model's remaining scatter is 2259 MW, still well above the TSOs' 1206. Systematic under-prediction is therefore the largest single piece of the gap, not the whole of it.

The cause is installed capacity. Germany added roughly 14 GW of PV between the training year and the test year, and a tree ensemble cannot extrapolate past the target range it was trained on.

In [10]:
peak = feat["pv_actual"].resample("MS").max()
roll = feat["pv_actual"].rolling(24 * 30, min_periods=24 * 7).max()

fig, ax = plt.subplots(figsize=(11, 4))
ax.plot(roll.index, roll, lw=1, label="30-day rolling max of pv_actual")
ax.plot(peak.index, peak, "o-", ms=4, lw=1, alpha=0.7, label="monthly peak")
ax.axvline(pd.Timestamp(f"{TEST}-01-01", tz="UTC"), color="k", ls="--", lw=1)
ax.text(pd.Timestamp(f"{TEST}-01-05", tz="UTC"), 5000, " test period", fontsize=9)
ax.set(title="Installed capacity is a moving target",
       ylabel="MW", xlabel="")
ax.legend(loc="upper left")
plt.tight_layout()
plt.show()

s23 = feat.loc[TRAIN, "pv_actual"].max()
s24 = feat.loc[TEST, "pv_actual"].max()
print(f"peak generation {TRAIN}: {s23:,.0f} MW   {TEST}: {s24:,.0f} MW   "
      f"(+{100 * (s24 / s23 - 1):.0f}%)")
No description has been provided for this image
peak generation 2023: 41,246 MW   2024: 46,898 MW   (+14%)

What the drift costs

A fix that stays inside the leakage rules: normalise the target by a capacity proxy built only from data at least HORIZON_H old — the 30-day rolling maximum of generation, shifted 24 h. Predict the ratio, then multiply back. It is not true installed capacity (winter maxima are weather-limited), but it tracks the fleet's recent achievable output, which is what the model is missing.

This is a diagnostic, not the headline model. It stays out of the results table above so those numbers match the README.

In [11]:
cap = feat["pv_actual"].shift(HORIZON_H).rolling(24 * 30, min_periods=24 * 14).max()
assert "cap" not in cols, "capacity proxy must not become a feature"

g = feat.assign(cap=cap).dropna(subset=NEED + ["cap"])
tr_r, te_r = g.loc[TRAIN], g.loc[TEST]

ratio = (tr_r["pv_actual"] / tr_r["cap"]).clip(0, 1.5)
gbr_r = HistGradientBoostingRegressor(random_state=SEED).fit(tr_r[cols], ratio)
model_scaled = (pd.Series(gbr_r.predict(te_r[cols]), index=te_r.index)
                * te_r["cap"]).clip(0)

cmp = preds.copy()
cmp["model_capacity_scaled"] = model_scaled
tab2 = score_table(test, cmp)
tab2["bias_day"] = [
    (cmp[n] - test["pv_actual"])[test["is_day"]].mean().round(0) for n in cmp.columns
]
print(tab2.loc[["clearsky_persistence", "model", "model_capacity_scaled",
                "tso_forecast"], ["MAE_day", "RMSE_day", "nMAE_day_%", "bias_day"]])
                       MAE_day  RMSE_day  nMAE_day_%  bias_day
method                                                        
clearsky_persistence    3755.3    5291.1         8.0     -11.0
model                   2491.7    3459.6         5.3   -2012.0
model_capacity_scaled   1987.4    2739.9         4.2    -224.0
tso_forecast            1213.7    1717.0         2.6     222.0

Normalising by the capacity proxy takes daylight MAE from 2492 to 1987 MW (-20%) and cuts the bias from -2012 to -224 MW, for no new information and no tuning. The gap to the TSOs narrows from +105% to +64%.

That reframes the headline result. The model's weather skill was never the main problem — being anchored to last year's fleet was. It also says something about the framing of the whole exercise: training on one year and testing on the next is honest about time ordering, but it hands the model a target whose scale has moved underneath it.

Diagnostics

Everything below is scored on the same rows as the table above — score_table drops any hour where any method is NaN (8 hours where clear-sky persistence is missing), so the frame is rebuilt the same way here rather than plotting all 8784 hours and letting each method average over a different set.

In [12]:
METHODS = ["clearsky_persistence", "model", "tso_forecast"]
COLOURS = {"clearsky_persistence": "tab:grey", "model": "tab:blue",
           "tso_forecast": "tab:orange"}

d = (pd.concat([test[["pv_actual", "is_day", "cloud_cover_de"]], preds], axis=1)
       .dropna(subset=list(preds.columns) + ["pv_actual"]))
for name in METHODS:
    d[f"e_{name}"] = (d[name] - d["pv_actual"]).abs()
err_cols = [f"e_{name}" for name in METHODS]

print(f"{len(d):,} rows, matching the {tab.attrs['n_hours']:,} scored above")


def style(ax, title, ylabel, xlabel="", legend=METHODS):
    """legend=None keeps whatever labels the artists already carry."""
    ax.set(title=title, ylabel=ylabel, xlabel=xlabel)
    ax.legend(legend) if legend else ax.legend()
8,776 rows, matching the 8,776 scored above

Does it track a real day?

Two weeks from the test year: the clearest and the cloudiest week of the Apr-Sep half, chosen by mean daylight cloud cover so the contrast is weather and not season.

In [13]:
day_rows = d[d["is_day"]]
summer = day_rows[(day_rows.index.month >= 4) & (day_rows.index.month <= 9)]
weekly = summer.groupby(pd.Grouper(freq="W-MON"))["cloud_cover_de"].agg(["mean", "count"])
weekly = weekly[weekly["count"] >= 90]

picks = [("clearest", weekly["mean"].idxmin(), weekly["mean"].min()),
         ("cloudiest", weekly["mean"].idxmax(), weekly["mean"].max())]

fig, axes = plt.subplots(2, 1, figsize=(11, 7), sharey=True)
for ax, (label, end, cc) in zip(axes, picks):
    w = d.loc[end - pd.Timedelta(days=7):end]
    ax.plot(w.index, w["pv_actual"], color="k", lw=1.8, label="actual")
    for name in METHODS:
        ax.plot(w.index, w[name], lw=1.2, alpha=0.85, color=COLOURS[name], label=name)
    ax.set(title=f"{label} week of Apr-Sep {TEST} "
                 f"({(end - pd.Timedelta(days=7)).date()} to {end.date()}, "
                 f"mean daylight cloud cover {cc:.0f}%)",
           ylabel="MW")
    ax.legend(ncol=4, fontsize=8)
plt.tight_layout()
plt.show()
No description has been provided for this image

The clear week makes the bias visible: the model traces the right shape but sits under the peak on every single day, while the TSO forecast lies almost on top of the actual. Clear-sky persistence is the one that swings — it carries yesterday's sky into today, so it overshoots by 7 GW on 08-07 and 08-09, the mornings after brighter days.

On the cloudy week the amplitude changes day to day, and that is what has to be predicted. The TSOs get it; the model gets the shape and misses the level; persistence is a day behind throughout.

Error by hour of day

In [14]:
by_hour = d.groupby(d.index.hour)[err_cols].mean()

# relative: daylight only, since dividing by a near-zero night mean explodes
mean_by_hour = day_rows.groupby(day_rows.index.hour)["pv_actual"].mean()
rel_hour = 100 * day_rows.groupby(day_rows.index.hour)[err_cols].mean().div(
    mean_by_hour, axis=0)

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
by_hour.plot(ax=axes[0], color=[COLOURS[m] for m in METHODS])
style(axes[0], f"Absolute error by hour, {TEST}", "MAE [MW]", "hour (UTC)")
rel_hour.plot(ax=axes[1], color=[COLOURS[m] for m in METHODS])
style(axes[1], f"Relative error by hour (daylight), {TEST}",
      "MAE / mean output [%]", "hour (UTC)")
plt.tight_layout()
plt.show()
No description has been provided for this image

Absolute error peaks at midday because that is when there is generation to be wrong about. Normalised by mean output the curves go nearly flat — the model holds 14-18% across the whole day, the TSOs 6-8% — which says the midday hump on the left is mostly a picture of where the MW are, not of hours that are disproportionately hard. The mild lift at the shoulders is the sunrise and sunset hours, where a small timing error is a large fraction of a small number.

Error through the year

In [15]:
daily = d[err_cols].resample("D").mean()
daily_pv = d["pv_actual"].resample("D").mean()
smooth = daily.rolling(30, center=True, min_periods=10).mean()
rel_smooth = (100 * daily.div(daily_pv, axis=0)).rolling(
    30, center=True, min_periods=10).mean()

fig, axes = plt.subplots(2, 1, figsize=(11, 7))
smooth.plot(ax=axes[0], color=[COLOURS[m] for m in METHODS])
style(axes[0], f"Absolute error through {TEST} (30-day running mean)", "MAE [MW]")
rel_smooth.plot(ax=axes[1], color=[COLOURS[m] for m in METHODS])
style(axes[1], f"Relative error through {TEST} (30-day running mean)",
      "MAE / mean output [%]")
plt.tight_layout()
plt.show()
No description has been provided for this image
In [16]:
monthly = day_rows.groupby(day_rows.index.month)[err_cols].mean()
monthly.columns = METHODS
monthly.index.name = "month"
monthly["mean_output"] = day_rows.groupby(day_rows.index.month)["pv_actual"].mean()
monthly["model_rel_%"] = 100 * monthly["model"] / monthly["mean_output"]
print(f"daylight MAE by month, {TEST} [MW]\n")
print(monthly.round(0))
daylight MAE by month, 2024 [MW]

       clearsky_persistence   model  tso_forecast  mean_output  model_rel_%
month                                                                      
1                    2901.0  1406.0        1160.0       5984.0         23.0
2                    3431.0  1641.0        1025.0       8554.0         19.0
3                    4653.0  2334.0        1569.0      14520.0         16.0
4                    4347.0  2439.0        1357.0      17529.0         14.0
5                    3738.0  2920.0        1165.0      19387.0         15.0
6                    3925.0  3067.0        1221.0      19591.0         16.0
7                    4462.0  3478.0        1156.0      20928.0         17.0
8                    4075.0  3930.0         958.0      21685.0         18.0
9                    4776.0  2250.0        1131.0      17810.0         13.0
10                   2692.0  1588.0        1392.0      11855.0         13.0
11                   2215.0  1432.0        1457.0       6448.0         22.0
12                   1781.0   961.0        1016.0       4688.0         21.0

The model's monthly MAE swings by a factor of four, 961 MW in December to 3930 MW in August, tracking the seasonal cycle of generation. The TSOs' does not: theirs sits between 958 and 1569 MW all year. That contrast is the capacity bias again — a roughly proportional shortfall costs little in a dark month and a great deal in a bright one.

In relative terms the ranking flips, and the model is worst in the dark months (21-23% in Nov-Jan against 13-15% in Apr-Sep), where there is little output to divide by.

The days it got badly wrong

In [17]:
by_day = day_rows.resample("D")[err_cols + ["pv_actual", "cloud_cover_de"]].mean()
worst = by_day["e_model"].dropna().nlargest(8)
detail = by_day.loc[worst.index, ["e_model", "e_tso_forecast", "pv_actual",
                                  "cloud_cover_de"]]
detail.columns = ["model_MAE", "tso_MAE", "mean_actual", "cloud_cover_%"]
print(f"worst 8 days for the model, daylight MAE [MW]\n")
print(detail.round(0))
worst 8 days for the model, daylight MAE [MW]

                           model_MAE  tso_MAE  mean_actual  cloud_cover_%
timestamp                                                                
2024-01-27 00:00:00+00:00     7587.0   3322.0      15204.0           22.0
2024-07-18 00:00:00+00:00     7327.0    895.0      26504.0           77.0
2024-04-29 00:00:00+00:00     6592.0   2216.0      24676.0           85.0
2024-06-24 00:00:00+00:00     6530.0    632.0      26148.0           45.0
2024-08-05 00:00:00+00:00     6078.0   1092.0      22311.0           58.0
2024-05-09 00:00:00+00:00     5948.0   2295.0      25496.0           60.0
2024-07-19 00:00:00+00:00     5670.0    459.0      26011.0           68.0
2024-03-17 00:00:00+00:00     5538.0   3107.0      19825.0           86.0
In [18]:
bad = worst.index[0]
w = d.loc[bad.strftime("%Y-%m-%d")]

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(w.index, w["pv_actual"], color="k", lw=2, label="actual")
for name in METHODS:
    ax.plot(w.index, w[name], lw=1.4, color=COLOURS[name], label=name)
ax.fill_between(w.index, w["model"], w["pv_actual"], alpha=0.15, color="tab:blue")
style(ax, f"Worst day for the model: {bad.date()} "
          f"(daylight MAE {worst.iloc[0]:.0f} MW)", "MW", "hour (UTC)",
      legend=["actual"] + METHODS)
plt.tight_layout()
plt.show()
No description has been provided for this image

The worst days fall into two groups, and the tso_MAE column separates them.

Days only the model failed — 07-18, 06-24, 08-05, 07-19. High-output summer days, 22-26 GW, where the TSOs were off by 459-1092 MW and the model by 5700-7300. Nothing meteorological went wrong; the model was forecasting a smaller fleet.

Days everybody failed — 01-27 and 03-17, where the TSOs were also off by over 3 GW. 2024-01-27 is the standout and worth plotting: an exceptionally bright January day that produced 23.8 GW at noon, roughly four times the January average. Clear-sky persistence collapses to 4.7 GW because the day before was dark, the TSOs undershoot by 4.8 GW, and the model undershoots by 10.8 GW — it had never seen a January hour anywhere near that in 2023, and a tree cannot predict past the range of its training targets.

Uncertainty: P10 / P50 / P90

A point forecast is not enough for anything downstream — reserve sizing, trading, curtailment decisions all need a distribution. HistGradientBoosting fits quantiles directly with the pinball loss.

Two checks, and they answer different questions. Pinball loss scores sharpness and calibration together, lower is better. Coverage asks whether the interval means what it claims: 80% of actuals should land between P10 and P90.

In [19]:
def fit_quantiles(X_train, y_train, X_test, scale=None, taus=(0.1, 0.5, 0.9)):
    """One fit per quantile; `scale` multiplies predictions back to MW."""
    out = {}
    for tau in taus:
        m = HistGradientBoostingRegressor(loss="quantile", quantile=tau,
                                          random_state=SEED)
        m.fit(X_train, y_train)
        p = pd.Series(m.predict(X_test), index=X_test.index)
        out[f"p{int(tau * 100)}"] = (p if scale is None else p * scale).clip(0)
    q = pd.DataFrame(out)
    # separate fits can cross; enforce p10 <= p50 <= p90
    q["p50"] = q[["p10", "p50"]].max(axis=1)
    q["p90"] = q[["p50", "p90"]].max(axis=1)
    return q


def report(q):
    y = test["pv_actual"].reindex(q.index)
    day = test["is_day"].reindex(q.index)
    inside = (y >= q["p10"]) & (y <= q["p90"])
    return {
        "pinball_p10": mean_pinball_loss(y[day], q["p10"][day], alpha=0.1),
        "pinball_p50": mean_pinball_loss(y[day], q["p50"][day], alpha=0.5),
        "pinball_p90": mean_pinball_loss(y[day], q["p90"][day], alpha=0.9),
        "coverage_%": 100 * inside[day].mean(),
        "below_p10_%": 100 * (y < q["p10"])[day].mean(),
        "above_p90_%": 100 * (y > q["p90"])[day].mean(),
        "mean_width": (q["p90"] - q["p10"])[day].mean(),
    }


q_raw = fit_quantiles(train[cols], train["pv_actual"], test[cols])
q_scaled = fit_quantiles(tr_r[cols], ratio, te_r[cols], scale=te_r["cap"])

print("daylight hours; nominal coverage 80%, 10% in each tail\n")
print(pd.DataFrame({"raw target": report(q_raw),
                    "capacity-scaled": report(q_scaled)}).T.round(1))
daylight hours; nominal coverage 80%, 10% in each tail

                 pinball_p10  pinball_p50  pinball_p90  coverage_%  below_p10_%  above_p90_%  mean_width
raw target             462.3       1242.6       1100.0        51.2          6.9         42.0      4262.5
capacity-scaled        426.3        990.5        630.1        67.1         13.8         19.1      5354.0

The raw-target interval covers 51% instead of 80%, and the miss is one-sided — 42% of daylight hours land above P90 against 7% below P10. That is not an uncertainty problem, it is the capacity bias again: the whole predictive distribution sits too low, so the upper tail is where reality keeps turning up.

Capacity-scaling fixes the asymmetry (14% below / 19% above, near enough symmetric) and lifts coverage to 67%. What remains is the ordinary failure mode of quantile GBMs — the interval is too narrow. Widening it is a calibration job: conformal prediction, or the EasyUQ-style approach of fitting the predictive distribution to the point forecast rather than fitting each quantile separately.

In [20]:
end = picks[1][1]                       # the cloudy week, where the band matters
w = q_scaled.loc[end - pd.Timedelta(days=5):end]
y = test["pv_actual"].reindex(w.index)

fig, ax = plt.subplots(figsize=(11, 4))
ax.fill_between(w.index, w["p10"], w["p90"], alpha=0.3, color="tab:blue",
                label="P10-P90")
ax.plot(w.index, w["p50"], color="tab:blue", lw=1.4, label="P50")
ax.plot(y.index, y, color="k", lw=1.8, label="actual")
ax.plot(w.index, preds["tso_forecast"].reindex(w.index), color="tab:orange",
        lw=1.2, alpha=0.8, label="tso_forecast")
inside = ((y >= w["p10"]) & (y <= w["p90"]))
ax.set(title=f"Capacity-scaled quantiles, cloudy week of {TEST} "
             f"({100 * inside.mean():.0f}% of hours inside the band)",
       ylabel="MW", xlabel="")
ax.legend(ncol=4, fontsize=8)
plt.tight_layout()
plt.show()
No description has been provided for this image

The band does what it should qualitatively — narrow at night and on the well-behaved days, widest through the middle of cloudy ones — and 74% of hours in this window land inside it. The misses are all at the peaks, where the actual pushes out of the top of the band: the same too-narrow, slightly-low signature the table reports.

What the model actually uses

Permutation importance on the test set: shuffle one column, see how much daylight MAE gets worse. Reported in MW, so the numbers are directly comparable to the error figures above.

In [21]:
imp = permutation_importance(
    gbr, test[cols], test["pv_actual"], n_repeats=5, random_state=SEED,
    scoring="neg_mean_absolute_error", n_jobs=4)
ranked = pd.Series(imp.importances_mean, index=cols).sort_values()

fig, ax = plt.subplots(figsize=(8, 6))
ranked.tail(15).plot.barh(ax=ax, color="tab:blue",
                          xerr=pd.Series(imp.importances_std, index=cols).reindex(
                              ranked.tail(15).index))
ax.set(title="Permutation importance, top 15", xlabel="increase in MAE [MW]",
       ylabel="")
plt.tight_layout()
plt.show()
No description has been provided for this image

pv_lag24 dominates at ~2300 MW, then national direct and shortwave radiation, then solar geometry. The city weather columns barely register individually — with eight highly correlated cities, shuffling one leaves the other seven carrying the same information, so single-column importance understates the group.

Two of the per-city columns do place high, solar_zenith_berlin and ghi_cs_berlin, and they are not a statement about Berlin's weather: ghi_cs_* and solar_zenith_* are deterministic astronomy, near-identical across the eight cities, and the model simply picked one of them as its clock. Permuting each city's six genuine weather columns as a group — excluding those two — answers the question that was actually being asked.

In [22]:
WEATHER = ["shortwave_radiation", "direct_radiation", "diffuse_radiation",
           "temperature_2m", "cloud_cover", "cloud_idx"]

rng = np.random.default_rng(SEED)
day_mask = test["is_day"].to_numpy()
y_true = test["pv_actual"].to_numpy()
base = np.abs(gbr.predict(test[cols]).clip(0) - y_true)[day_mask].mean()

rows = []
for city in SITES:
    city_cols = [f"{w}_{city}" for w in WEATHER]
    ups = []
    for _ in range(5):
        X = test[cols].copy()
        X[city_cols] = X[city_cols].to_numpy()[rng.permutation(len(X))]
        ups.append(np.abs(gbr.predict(X).clip(0) - y_true)[day_mask].mean() - base)
    rows.append({"city": city, "dMAE_day": np.mean(ups), "std": np.std(ups)})

group = pd.DataFrame(rows).set_index("city").sort_values("dMAE_day", ascending=False)
print(f"daylight MAE {base:.0f} MW; increase when a city's weather is shuffled\n")
print(group.round(1))
daylight MAE 2487 MW; increase when a city's weather is shuffled

           dMAE_day  std
city                    
koeln         185.7  2.9
hamburg       116.6  3.1
muenchen      103.7  4.7
nuernberg      96.1  4.7
stuttgart      83.0  5.7
leipzig        23.9  6.0
frankfurt      14.0  2.5
berlin          9.3  3.5

Every city lands between 9 and 186 MW — all of them small next to the 2000 MW bias, and close enough together that the ordering is barely a signal.

It is also the wrong ordering. Bavaria and Baden-Württemberg hold far more installed PV than the north, so muenchen and stuttgart should lead; instead koeln and hamburg do, and berlin comes last. The honest reading is that the eight cities are so correlated that the model can substitute one for another freely, and an unweighted mean of eight points is a crude stand-in for a national fleet.

The intended comparison — this ranking against the real capacity distribution from the Marktstammdatenregister — is still to do, and a capacity-weighted aggregation is the fix it points to.

Where this leaves things

method daylight MAE [MW] vs TSO
clear-sky persistence 3755 +209%
model 2492 +105%
model, capacity-scaled 1987 +64%
TSO forecast 1214 —

What the exercise showed, in order of how much it mattered:

  1. The largest single error source was not meteorology. A tree ensemble trained on 2023 cannot forecast a 2024 fleet that is 14% larger, and that bias was the biggest single piece of the daylight MAE. Normalising by a lagged capacity proxy recovered a fifth of the error for free.
  2. The quantile intervals inherited that bias before they were anything else. Coverage diagnostics found it immediately; pinball loss alone would have hidden it.
  3. Remaining gap to the TSOs is real but smaller than it first looked, and it sits mostly in cloudy shoulder hours.

Still open:

  • Recalibrate the intervals — conformal or EasyUQ-style — for the 67% -> 80% coverage gap.
  • Capacity-weight the city aggregation using Marktstammdatenregister data instead of an unweighted mean of eight cities.
  • Swap ERA5 for real IFS/AIFS forecasts and quantify the drop. Every number here assumes near-perfect weather input; the train/serve mismatch is the genuinely interesting result and this notebook does not measure it.