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.
Contents
- Data and features
- Split
- Baselines
- The model
- Reading the table
- The caveat that qualifies all of this
- Where the error comes from
- What the drift costs
- Diagnostics
- Does it track a real day?
- Error by hour of day
- Error through the year
- The days it got badly wrong
- Uncertainty: P10 / P50 / P90
- What the model actually uses
- 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.
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})
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")
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.
- No leakage. Anything derived from an observation is shifted by at least
HORIZON_H= 24 h.add_lagsraises if a lag is shorter. - 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 formsk_cs_lag24andload_lag24are fine.
feature_columns() enforces rule 2; the assertion below is the receipt.
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)}")
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.
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"
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.
preds = all_baselines(feat).loc[test.index]
print(score_table(test, preds))
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.
gbr = HistGradientBoostingRegressor(random_state=SEED)
gbr.fit(train[cols], train["pv_actual"])
preds["model"] = gbr.predict(test[cols]).clip(0)
print(gbr)
# 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 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.
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)
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.
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))
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.
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}%)")
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.
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"]])
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.
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()
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.
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()
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¶
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()
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¶
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()
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))
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¶
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))
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()
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.
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))
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.
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()
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.
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()
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.
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))
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:
- 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.
- The quantile intervals inherited that bias before they were anything else. Coverage diagnostics found it immediately; pinball loss alone would have hidden it.
- 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.